V0.6.2 docs openapi (#37)
All checks were successful
All checks were successful
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com> Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
This commit was merged in pull request #37.
This commit is contained in:
372
server/handlers/backup_test.go
Normal file
372
server/handlers/backup_test.go
Normal file
@@ -0,0 +1,372 @@
|
||||
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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user