This repository has been archived on 2026-04-03. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
core/server/handlers/backup_test.go
Jeffrey Smith a887b4c78b
All checks were successful
CI/CD / detect-changes (push) Successful in 4s
CI/CD / test-frontend (push) Successful in 6s
CI/CD / test-go-pg (push) Successful in 2m46s
CI/CD / test-sqlite (push) Successful in 2m49s
CI/CD / build-and-deploy (push) Successful in 28s
V0.6.2 docs openapi (#37)
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
2026-03-31 12:01:51 +00:00

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))
}
}