package handlers import ( "archive/zip" "bufio" "bytes" "context" "encoding/json" "mime/multipart" "net/http" "net/http/httptest" "testing" "github.com/gin-gonic/gin" authpkg "switchboard-core/auth" "switchboard-core/config" "switchboard-core/database" "switchboard-core/middleware" "switchboard-core/store" "switchboard-core/store/sqlite" ) // ── Backup Test Harness ──────────────────────── type backupHarness struct { *testHarness adminToken string adminID string stores store.Stores backupH *BackupHandler } func setupBackupHarness(t *testing.T) *backupHarness { t.Helper() database.RequireTestDB(t) database.TruncateAll(t) cfg := &config.Config{ JWTSecret: testJWTSecret, BasePath: "", } var stores store.Stores if database.IsSQLite() { stores = sqlite.NewStores(database.TestDB) } else { t.Skip("backup tests run on SQLite only") } userCache := middleware.NewUserStatusCache() storagePath := t.TempDir() backupH := NewBackupHandler(stores, "", storagePath) r := gin.New() api := r.Group("/api/v1") // Auth auth := NewAuthHandler(cfg, stores, nil, authpkg.NewBuiltinProvider()) api.POST("/auth/register", auth.Register) api.POST("/auth/login", auth.Login) // Admin group admin := api.Group("/admin") admin.Use(middleware.Auth(cfg, stores.Users, userCache), middleware.RequireAdmin(stores)) admin.POST("/backup", backupH.CreateBackup) admin.GET("/backups", backupH.ListBackups) admin.GET("/backups/:name", backupH.DownloadBackup) admin.DELETE("/backups/:name", backupH.DeleteBackup) admin.POST("/restore", backupH.RestoreBackup) // Seed admin adminID := seedInsertReturningID(t, `INSERT INTO users (username, email, password_hash, handle, auth_source) VALUES ($1, $2, $3, $4, $5) RETURNING id`, "bk-admin", "bk-admin@test.com", "$2a$10$test", "bk-admin", "builtin", ) database.SeedEveryoneGroupMember(t, adminID) database.SeedAdminsGroupMember(t, adminID) adminToken := makeToken(adminID, "bk-admin@test.com", "admin") return &backupHarness{ testHarness: &testHarness{router: r, t: t}, adminToken: adminToken, adminID: adminID, stores: stores, backupH: backupH, } } // ── Tests ────────────────────────────────────── func TestCreateBackup_Basic(t *testing.T) { h := setupBackupHarness(t) w := h.request("POST", "/api/v1/admin/backup", h.adminToken, nil) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } // Verify it's a valid ZIP data := w.Body.Bytes() zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data))) if err != nil { t.Fatalf("invalid ZIP: %v", err) } // Must contain meta.json var foundMeta bool for _, f := range zr.File { if f.Name == "meta.json" { foundMeta = true rc, _ := f.Open() var meta backupMeta json.NewDecoder(rc).Decode(&meta) rc.Close() if meta.Dialect != "sqlite" { t.Errorf("expected dialect sqlite, got %s", meta.Dialect) } if len(meta.CoreTables) == 0 { t.Error("expected core tables in meta") } } } if !foundMeta { t.Fatal("meta.json not found in backup") } // Must contain core table JSONL files var foundUsers bool for _, f := range zr.File { if f.Name == "core/users.jsonl" { foundUsers = true } } if !foundUsers { t.Error("core/users.jsonl not found in backup") } } func TestCreateBackup_WithExtData(t *testing.T) { h := setupBackupHarness(t) ctx := context.Background() // Seed a package so FK constraint is satisfied database.TestDB.ExecContext(ctx, dialectSQL( `INSERT INTO packages (id, title, type, tier, version, source, enabled, manifest) VALUES ($1, $2, $3, $4, $5, $6, 1, '{}')`), "testpkg", "Test Pkg", "extension", "starlark", "0.1.0", "extension", ) // Register an ext_data table and create it database.TestDB.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS ext_testpkg_items ( id TEXT PRIMARY KEY, name TEXT NOT NULL, created_at TEXT DEFAULT (datetime('now')) )`) if _, err := database.TestDB.ExecContext(ctx, dialectSQL(`INSERT INTO ext_data_tables (package_id, table_name) VALUES ($1, $2)`), "testpkg", "items", ); err != nil { t.Fatalf("insert ext_data_tables: %v", err) } database.TestDB.ExecContext(ctx, `INSERT INTO ext_testpkg_items (id, name) VALUES ('item1', 'Test Item')`, ) defer database.TestDB.ExecContext(ctx, "DROP TABLE IF EXISTS ext_testpkg_items") w := h.request("POST", "/api/v1/admin/backup", h.adminToken, nil) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d", w.Code) } data := w.Body.Bytes() zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data))) if err != nil { t.Fatalf("invalid ZIP: %v", err) } // Should contain ext_data/testpkg/items.jsonl var foundExt bool for _, f := range zr.File { if f.Name == "ext_data/testpkg/items.jsonl" { foundExt = true rc, _ := f.Open() scanner := bufio.NewScanner(rc) if !scanner.Scan() { t.Error("ext_data file is empty") } else { var row map[string]interface{} json.Unmarshal(scanner.Bytes(), &row) if row["name"] != "Test Item" { t.Errorf("expected 'Test Item', got %v", row["name"]) } } rc.Close() } } if !foundExt { t.Error("ext_data/testpkg/items.jsonl not found") } // Check meta.json has ext table listed metaFile := findInZip(zr, "meta.json") rc, _ := metaFile.Open() var meta backupMeta json.NewDecoder(rc).Decode(&meta) rc.Close() found := false for _, et := range meta.ExtTables { if et == "ext_testpkg_items" { found = true } } if !found { t.Errorf("ext_testpkg_items not in meta.ExtTables: %v", meta.ExtTables) } } func TestRestoreBackup_RoundTrip(t *testing.T) { h := setupBackupHarness(t) // Create a second user so we have data to verify seedInsertReturningID(t, `INSERT INTO users (username, email, password_hash, handle, auth_source) VALUES ($1, $2, $3, $4, $5) RETURNING id`, "bk-user2", "bk-user2@test.com", "$2a$10$test", "bk-user2", "builtin", ) // Create backup w := h.request("POST", "/api/v1/admin/backup", h.adminToken, nil) if w.Code != http.StatusOK { t.Fatalf("backup: expected 200, got %d: %s", w.Code, w.Body.String()) } backupData := w.Body.Bytes() // Verify we have 2 users var countBefore int database.TestDB.QueryRow("SELECT COUNT(*) FROM users").Scan(&countBefore) if countBefore < 2 { t.Fatalf("expected at least 2 users, got %d", countBefore) } // Restore directly (the handler wipes + restores, so data should round-trip) // We use a separate router without admin middleware for restore, // since the wipe inside RestoreBackup removes the admin group membership // mid-request. In production, restore would use a session-based token // that survives the wipe. For testing, we bypass the middleware. restoreRouter := gin.New() restoreRouter.POST("/restore", h.backupH.RestoreBackup) body := &bytes.Buffer{} writer := multipart.NewWriter(body) part, _ := writer.CreateFormFile("file", "test-backup.swb") part.Write(backupData) writer.Close() req := httptest.NewRequest("POST", "/restore", body) req.Header.Set("Content-Type", writer.FormDataContentType()) rw := httptest.NewRecorder() restoreRouter.ServeHTTP(rw, req) if rw.Code != http.StatusOK { t.Fatalf("restore: expected 200, got %d: %s", rw.Code, rw.Body.String()) } // Verify data is back var countAfterRestore int database.TestDB.QueryRow("SELECT COUNT(*) FROM users").Scan(&countAfterRestore) if countAfterRestore != countBefore { t.Errorf("expected %d users after restore, got %d", countBefore, countAfterRestore) } // Verify specific user exists var username string err := database.TestDB.QueryRow("SELECT username FROM users WHERE username = 'bk-user2'").Scan(&username) if err != nil { t.Errorf("user bk-user2 not found after restore: %v", err) } } func TestRestoreBackup_SchemaVersionMismatch(t *testing.T) { h := setupBackupHarness(t) // Build a fake backup with a future schema version var buf bytes.Buffer zw := zip.NewWriter(&buf) mw, _ := zw.Create("meta.json") json.NewEncoder(mw).Encode(backupMeta{ Version: "99.0.0", SchemaVersion: "999_future", Dialect: "sqlite", CoreTables: []string{"users"}, }) zw.Close() body := &bytes.Buffer{} writer := multipart.NewWriter(body) part, _ := writer.CreateFormFile("file", "future.swb") part.Write(buf.Bytes()) writer.Close() req := httptest.NewRequest("POST", "/api/v1/admin/restore", body) req.Header.Set("Content-Type", writer.FormDataContentType()) req.Header.Set("Authorization", "Bearer "+h.adminToken) rw := httptest.NewRecorder() h.router.ServeHTTP(rw, req) if rw.Code != http.StatusBadRequest { t.Fatalf("expected 400 for future schema, got %d: %s", rw.Code, rw.Body.String()) } } func TestDumpAndRestoreTable(t *testing.T) { database.RequireTestDB(t) ctx := context.Background() db := database.TestDB // Create a test table db.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS test_backup_round ( id TEXT PRIMARY KEY, name TEXT NOT NULL, count INTEGER DEFAULT 0 )`) defer db.ExecContext(ctx, "DROP TABLE IF EXISTS test_backup_round") // Insert data db.ExecContext(ctx, `INSERT INTO test_backup_round (id, name, count) VALUES ('r1', 'alpha', 10)`) db.ExecContext(ctx, `INSERT INTO test_backup_round (id, name, count) VALUES ('r2', 'beta', 20)`) // Dump var buf bytes.Buffer count, err := dumpTable(ctx, db, "test_backup_round", &buf) if err != nil { t.Fatalf("dump: %v", err) } if count != 2 { t.Fatalf("dump: expected 2 rows, got %d", count) } // Wipe db.ExecContext(ctx, "DELETE FROM test_backup_round") // Restore restored, err := restoreTable(ctx, db, "test_backup_round", &buf) if err != nil { t.Fatalf("restore: %v", err) } if restored != 2 { t.Fatalf("restore: expected 2 rows, got %d", restored) } // Verify var name string var cnt int db.QueryRowContext(ctx, "SELECT name, count FROM test_backup_round WHERE id = 'r1'").Scan(&name, &cnt) if name != "alpha" || cnt != 10 { t.Errorf("expected alpha/10, got %s/%d", name, cnt) } } func TestListBackups_Empty(t *testing.T) { h := setupBackupHarness(t) w := h.request("GET", "/api/v1/admin/backups", h.adminToken, nil) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d", w.Code) } var resp struct{ Data []interface{} } json.NewDecoder(w.Body).Decode(&resp) if len(resp.Data) != 0 { t.Errorf("expected empty list, got %d", len(resp.Data)) } }