package admin import ( "context" "encoding/json" "errors" "net/http" "os" "path/filepath" "strings" "testing" "time" "github.com/gin-gonic/gin" "spark-mcp-go/internal/audit" "spark-mcp-go/internal/storage" "spark-mcp-go/internal/uploads" ) func newTestAdminUploads(t *testing.T) (*gin.Engine, *storage.UploadRepo, *storage.SubmissionRepo, *audit.Repo, *uploads.Store) { t.Helper() db, err := storage.Open(":memory:") if err != nil { t.Fatalf("open: %v", err) } t.Cleanup(func() { _ = db.Close() }) uploadStore, err := uploads.New(filepath.Join(t.TempDir(), "uploads")) if err != nil { t.Fatalf("new upload store: %v", err) } r := gin.New() Mount(r, db.Clusters(), db.Uploads(), db.Submissions(), audit.NewRepo(db), &uploadStore, []string{"good-token"}) return r, db.Uploads(), db.Submissions(), audit.NewRepo(db), &uploadStore } func TestUploadsWeb_RequiresAuth(t *testing.T) { r, _, _, _, _ := newTestAdminUploads(t) w := doReq(t, r, "GET", "/admin/uploads", "", nil) if w.Code != http.StatusUnauthorized { t.Errorf("GET /admin/uploads without auth: got %d, want %d", w.Code, http.StatusUnauthorized) } w = doReq(t, r, "GET", "/admin/uploads", "good-token", nil) if w.Code != http.StatusOK { t.Errorf("GET /admin/uploads with auth: got %d, want %d", w.Code, http.StatusOK) } ct := w.Header().Get("Content-Type") if !strings.Contains(ct, "text/html") { t.Errorf("Content-Type = %q, want text/html", ct) } body := w.Body.String() if !strings.Contains(body, "href='/admin/uploads'") && !strings.Contains(body, "href=\"/admin/uploads\"") { t.Errorf("body missing uploads nav link") } if !strings.Contains(body, "uploads-body") { t.Errorf("body missing uploads table") } } func TestListUploads(t *testing.T) { r, repo, _, _, _ := newTestAdminUploads(t) ctx := context.Background() uploadedAt := time.Unix(0, time.Now().UnixNano()) if err := repo.Create(ctx, "00000000000000000000000000000001", "alpha.txt", 10, "deadbeef", uploadedAt); err != nil { t.Fatalf("create upload: %v", err) } if err := repo.Create(ctx, "00000000000000000000000000000002", "beta.txt", 20, "cafebabe", uploadedAt.Add(time.Second)); err != nil { t.Fatalf("create upload: %v", err) } w := doReq(t, r, "GET", "/admin/uploads/api", "good-token", nil) if w.Code != http.StatusOK { t.Fatalf("got status %d, want %d", w.Code, http.StatusOK) } var list []map[string]any if err := json.Unmarshal(w.Body.Bytes(), &list); err != nil { t.Fatalf("unmarshal list: %v", err) } if len(list) != 2 { t.Errorf("got %d uploads, want 2", len(list)) } w = doReq(t, r, "GET", "/admin/uploads/api?search=alpha", "good-token", nil) if w.Code != http.StatusOK { t.Fatalf("search: got status %d", w.Code) } if err := json.Unmarshal(w.Body.Bytes(), &list); err != nil { t.Fatalf("unmarshal search: %v", err) } if len(list) != 1 { t.Errorf("search result: got %d, want 1", len(list)) } } func TestDeleteUpload(t *testing.T) { r, repo, _, auditRepo, store := newTestAdminUploads(t) ctx := context.Background() id := "00000000000000000000000000000003" dataPath := filepath.Join(store.Root, id) metaPath := dataPath + ".meta.json" if err := os.WriteFile(dataPath, []byte("payload"), 0o640); err != nil { t.Fatalf("create data file: %v", err) } sc := map[string]any{ "name": "delete-me.txt", "size": 7, "sha256": "feedface", "uploaded_at": time.Now().Format(time.RFC3339Nano), } b, _ := json.Marshal(sc) if err := os.WriteFile(metaPath, b, 0o600); err != nil { t.Fatalf("create sidecar: %v", err) } if err := repo.Create(ctx, id, "delete-me.txt", 7, "feedface", time.Now()); err != nil { t.Fatalf("create db row: %v", err) } w := doReq(t, r, "DELETE", "/admin/uploads/"+id, "good-token", nil) if w.Code != http.StatusNoContent { t.Fatalf("delete: got status %d, want %d: %s", w.Code, http.StatusNoContent, w.Body.String()) } if _, err := repo.Get(ctx, id); !errors.Is(err, storage.ErrNotFound) { t.Errorf("db row still exists: %v", err) } if _, err := os.Stat(dataPath); !os.IsNotExist(err) { t.Errorf("data file still exists") } if _, err := os.Stat(metaPath); !os.IsNotExist(err) { t.Errorf("sidecar still exists") } entries, err := auditRepo.List(ctx, 100) if err != nil { t.Fatalf("list audit: %v", err) } found := false for _, e := range entries { if e.Action == audit.ActionUploadDelete && e.ClusterID == id { found = true break } } if !found { t.Errorf("missing upload.delete audit entry") } } func TestListUploads_SearchByAppID(t *testing.T) { r, uploadRepo, subRepo, _, _ := newTestAdminUploads(t) ctx := context.Background() uploadedAt := time.Unix(0, time.Now().UnixNano()) if err := uploadRepo.Create(ctx, "00000000000000000000000000000010", "gamma.py", 10, "deadbeef", uploadedAt); err != nil { t.Fatalf("create upload: %v", err) } if err := uploadRepo.Create(ctx, "00000000000000000000000000000011", "delta.py", 20, "cafebabe", uploadedAt.Add(time.Second)); err != nil { t.Fatalf("create upload: %v", err) } // Only gamma.py has an associated submission; searching by app_id should // still return it even though the filename doesn't match the query. if err := subRepo.Create(ctx, storage.Submission{ FileID: "00000000000000000000000000000010", AppID: "application_gamma_0001", TrackingURL: "http://gamma", ClusterID: "c1", ExitCode: 0, DurationMS: 100, SubmittedAt: time.Now(), }); err != nil { t.Fatalf("create submission: %v", err) } w := doReq(t, r, "GET", "/admin/uploads/api?search=application_gamma", "good-token", nil) if w.Code != http.StatusOK { t.Fatalf("search by app_id: got status %d: %s", w.Code, w.Body.String()) } var list []map[string]any if err := json.Unmarshal(w.Body.Bytes(), &list); err != nil { t.Fatalf("unmarshal search: %v", err) } if len(list) != 1 { t.Errorf("search by app_id result: got %d, want 1", len(list)) } if len(list) > 0 && list[0]["file_id"] != "00000000000000000000000000000010" { t.Errorf("file_id=%v, want upload 10", list[0]["file_id"]) } }