Add /admin/uploads HTML page and JSON API backed by the upload_files DB index. The page lists file_id, name, size (KB), uploaded_at, and sha256 with client-side search by name, column sort, and per-row delete. DELETE /admin/uploads/:id removes the DB index row and the on-disk data file plus .meta.json sidecar. Admin deletes are written to the audit log. Wire the new dependencies through admin.Mount and main.go, and run the backfill step on startup so pre-existing sidecars are indexed. Tests cover auth, HTML rendering, JSON search, delete removing all artifacts, and audit entry creation. Co-Authored-By: Claude <noreply@anthropic.com>
155 lines
4.4 KiB
Go
155 lines
4.4 KiB
Go
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, *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(), audit.NewRepo(db), &uploadStore, []string{"good-token"})
|
|
return r, db.Uploads(), 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")
|
|
}
|
|
}
|