Some checks failed
CI/CD / detect-changes (pull_request) Successful in 20s
CI/CD / test-frontend (pull_request) Successful in 6s
CI/CD / test-sqlite (pull_request) Has been cancelled
CI/CD / build-and-deploy (pull_request) Has been cancelled
CI/CD / test-go-pg (pull_request) Has been cancelled
v0.6.1 — Backup tooling (.swb ZIP export/import), admin backup section, docs surface with markdown rendering, 5 reference docs, 6 handler tests. v0.6.2 — Dark mode contrast fixes, topbar nav, 📖 docs icon, dynamic OpenAPI spec with extension route merging, docs outline sidebar, scroll/routing fixes; 7 new tests. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
373 lines
10 KiB
Go
373 lines
10 KiB
Go
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))
|
|
}
|
|
}
|