package api import ( "io" "net/http" "net/http/httptest" "os" "path/filepath" "testing" "github.com/alexei/tinyforge/internal/auth" "github.com/alexei/tinyforge/internal/store" "github.com/alexei/tinyforge/internal/volsnap" "github.com/alexei/tinyforge/internal/webhook" ) // newSnapshotEnv builds an API test env with the volume-snapshot engine wired // (the shared newAPITestEnv does not wire it). dataDir holds the snapshot // archives; baseVol is where host-bind volume directories resolve. func newSnapshotEnv(t *testing.T) (*apiTestEnv, string) { t.Helper() st, err := store.New(":memory:") if err != nil { t.Fatalf("create store: %v", err) } t.Cleanup(func() { st.Close() }) encKey := [32]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10} dispatcher := &fakeAPIDispatcher{} wh := webhook.NewHandler(st) wh.SetPluginDispatcher(dispatcher) srv := NewServer(st, nil, nil, nil, dispatcher, nil, wh, nil, encKey) snapEng, err := volsnap.New(st, t.TempDir()) if err != nil { t.Fatalf("snapshot engine: %v", err) } srv.SetSnapshotEngine(snapEng) httpsrv := httptest.NewServer(srv.Router()) t.Cleanup(httpsrv.Close) la := auth.NewLocalAuth(encKey) tok, err := la.GenerateToken(auth.Claims{UserID: "u-admin", Username: "admin", Role: "admin"}) if err != nil { t.Fatalf("mint token: %v", err) } baseVol := t.TempDir() settings, _ := st.GetSettings() settings.BaseVolumePath = baseVol if err := st.UpdateSettings(settings); err != nil { t.Fatalf("update settings: %v", err) } return &apiTestEnv{srv: httpsrv, store: st, dispatcher: dispatcher, adminToken: tok.Token, encKey: encKey}, baseVol } func TestVolumeSnapshots_EndToEnd(t *testing.T) { e, baseVol := newSnapshotEnv(t) w, err := e.store.CreateWorkload(store.Workload{ Name: "data-app", Kind: "project", SourceKind: "image", SourceConfig: `{"image":"registry.example.com/owner/app","port":8080}`, }) if err != nil { t.Fatalf("create workload: %v", err) } if _, err := e.store.SetWorkloadVolume(store.WorkloadVolume{ WorkloadID: w.ID, Target: "/data", Source: "data", Scope: "project", }); err != nil { t.Fatalf("set volume: %v", err) } // Materialize the resolved host-bind dir with a file so there is data to // capture. Layout mirrors ResolveWorkloadPath for project scope: // /-/. id8 := w.ID if len(id8) > 8 { id8 = id8[:8] } hostDir := filepath.Join(baseVol, "data-app-"+id8, "data") if err := os.MkdirAll(hostDir, 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(hostDir, "payload.txt"), []byte("important"), 0o644); err != nil { t.Fatal(err) } // snapshotable lists the one host-bind volume. resp := e.do(t, http.MethodGet, "/api/workloads/"+w.ID+"/snapshotable", nil) if resp.StatusCode != http.StatusOK { t.Fatalf("snapshotable status = %d", resp.StatusCode) } var snapable struct { Volumes []map[string]string `json:"volumes"` Skipped []map[string]string `json:"skipped"` } decodeEnvelope(t, resp, &snapable) if len(snapable.Volumes) != 1 || snapable.Volumes[0]["target"] != "/data" { t.Fatalf("expected 1 snapshotable volume /data, got %+v", snapable) } // Create a snapshot. resp = e.do(t, http.MethodPost, "/api/workloads/"+w.ID+"/snapshots", map[string]string{"label": "before upgrade"}) if resp.StatusCode != http.StatusCreated { t.Fatalf("create snapshot status = %d", resp.StatusCode) } var snap store.VolumeSnapshot decodeEnvelope(t, resp, &snap) if snap.ID == "" || snap.SizeBytes == 0 || snap.Label != "before upgrade" { t.Fatalf("unexpected snapshot: %+v", snap) } // It appears in the list. resp = e.do(t, http.MethodGet, "/api/workloads/"+w.ID+"/snapshots", nil) var list []store.VolumeSnapshot decodeEnvelope(t, resp, &list) if len(list) != 1 || list[0].ID != snap.ID { t.Fatalf("expected 1 snapshot in list, got %+v", list) } // Download streams a non-empty gzip archive (not the JSON envelope). resp = e.do(t, http.MethodGet, "/api/snapshots/"+snap.ID+"/download", nil) if resp.StatusCode != http.StatusOK { t.Fatalf("download status = %d", resp.StatusCode) } if ct := resp.Header.Get("Content-Type"); ct != "application/gzip" { t.Errorf("download content-type = %q, want application/gzip", ct) } data, _ := io.ReadAll(resp.Body) resp.Body.Close() if len(data) == 0 { t.Error("download body is empty") } // Delete removes it. resp = e.do(t, http.MethodDelete, "/api/snapshots/"+snap.ID, nil) if resp.StatusCode != http.StatusOK { t.Fatalf("delete status = %d", resp.StatusCode) } resp = e.do(t, http.MethodGet, "/api/workloads/"+w.ID+"/snapshots", nil) var after []store.VolumeSnapshot decodeEnvelope(t, resp, &after) if len(after) != 0 { t.Fatalf("expected 0 snapshots after delete, got %d", len(after)) } } func TestCreateSnapshot_NoVolumeData_Returns400(t *testing.T) { e, _ := newSnapshotEnv(t) w, err := e.store.CreateWorkload(store.Workload{ Name: "no-vol-app", Kind: "project", SourceKind: "image", SourceConfig: `{"image":"x","port":80}`, }) if err != nil { t.Fatalf("create workload: %v", err) } resp := e.do(t, http.MethodPost, "/api/workloads/"+w.ID+"/snapshots", nil) if resp.StatusCode != http.StatusBadRequest { t.Fatalf("expected 400 for an app with no snapshottable volumes, got %d", resp.StatusCode) } resp.Body.Close() } func TestSnapshotEndpoints_RequireWorkload(t *testing.T) { e, _ := newSnapshotEnv(t) // snapshotable on an unknown workload → 404. resp := e.do(t, http.MethodGet, "/api/workloads/does-not-exist/snapshotable", nil) if resp.StatusCode != http.StatusNotFound { t.Fatalf("snapshotable unknown workload = %d, want 404", resp.StatusCode) } resp.Body.Close() }