package gateway import ( "context" "encoding/json" "net/http" "os" "path/filepath" "strings" "testing" "git.yeij.top/AskaEth/Cyrene_v2/internal/auth" "git.yeij.top/AskaEth/Cyrene_v2/internal/config" "git.yeij.top/AskaEth/Cyrene_v2/internal/db" ) // newFileDB creates a real SQLite database file at dir/, migrates it, and // closes the handle, returning the file path. The gateway backup/restore // endpoints open their own connections, so the handle is not held open here. func newFileDB(t *testing.T, dir, name string) string { t.Helper() path := filepath.Join(dir, name) ctx := context.Background() conn, err := db.Open(ctx, path, gwTestLogger()) if err != nil { t.Fatalf("open file db: %v", err) } if err := db.MigrateUp(ctx, conn, gwTestLogger()); err != nil { _ = conn.Close() t.Fatalf("migrate: %v", err) } if err := conn.Close(); err != nil { t.Fatalf("close file db: %v", err) } return path } // --- revoke-keys ------------------------------------------------------------- func TestAdminRevokeAllKeys(t *testing.T) { t.Parallel() keys := auth.NewAPIKey(config.AuthConfig{}) for _, role := range []auth.Role{auth.RoleOwner, auth.RoleAdmin, auth.RoleViewer} { if _, err := keys.Generate(role); err != nil { t.Fatalf("generate key: %v", err) } } _, ts, jwt := buildEnv(t, cfgDefault(), Deps{Users: keys}) tok := signAny(t, jwt, "acct", auth.RoleOwner) resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/system/revoke-keys", nil, tok) if resp.StatusCode != http.StatusOK { t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body) } var res struct { Revoked int `json:"revoked"` } if err := json.Unmarshal(decodeBody(t, body)["data"], &res); err != nil { t.Fatalf("decode: %v", err) } if res.Revoked != 3 { t.Fatalf("revoked = %d, want 3", res.Revoked) } if len(keys.List()) != 0 { t.Fatalf("key table not empty after RevokeAll: %d", len(keys.List())) } } func TestAdminRevokeAllKeysUnwired(t *testing.T) { t.Parallel() _, ts, jwt := buildEnv(t, cfgDefault(), Deps{}) // no Users store tok := signAny(t, jwt, "acct", auth.RoleOwner) resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/system/revoke-keys", nil, tok) if resp.StatusCode != http.StatusInternalServerError { t.Fatalf("status = %d, want 500 (body=%s)", resp.StatusCode, body) } wantErrorShape(t, body) } // --- rotate-jwt -------------------------------------------------------------- func TestAdminRotateJWT(t *testing.T) { t.Parallel() jwt := auth.New(config.AuthConfig{JWTSecret: testSecret}) _, ts, _ := buildEnv(t, cfgDefault(), Deps{Auth: jwt}) // The route is owner-only; verify an agent with the current secret passes. tok := signAny(t, jwt, "acct", auth.RoleOwner) newSecret := strings.Repeat("Z", 40) resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/system/rotate-jwt", map[string]any{"newSecret": newSecret}, tok) if resp.StatusCode != http.StatusOK { t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body) } var res struct { Rotated bool `json:"rotated"` Note string `json:"note"` } if err := json.Unmarshal(decodeBody(t, body)["data"], &res); err != nil { t.Fatalf("decode: %v", err) } if !res.Rotated || res.Note == "" { t.Fatalf("res = %+v, want rotated with note", res) } // The pre-rotation token no longer verifies. if _, err := jwt.Verify(tok); err == nil { t.Fatal("pre-rotation token still verifies after RotateSecret") } // The new secret is never echoed back in the body, nor is a short one accepted. if strings.Contains(string(body), newSecret) { t.Fatalf("new secret leaked in response body: %s", body) } } func TestAdminRotateJWTShortSecret(t *testing.T) { t.Parallel() jwt := auth.New(config.AuthConfig{JWTSecret: testSecret}) _, ts, _ := buildEnv(t, cfgDefault(), Deps{Auth: jwt}) tok := signAny(t, jwt, "acct", auth.RoleOwner) resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/system/rotate-jwt", map[string]any{"newSecret": "short"}, tok) if resp.StatusCode != http.StatusBadRequest { t.Fatalf("status = %d, want 400 (body=%s)", resp.StatusCode, body) } wantErrorShape(t, body) // The previous secret stays intact after a rejected rotation. if _, err := jwt.Verify(tok); err != nil { t.Fatalf("token invalid after rejected rotation: %v", err) } } func TestAdminRotateJWTMissingSecret(t *testing.T) { t.Parallel() jwt := auth.New(config.AuthConfig{JWTSecret: testSecret}) _, ts, _ := buildEnv(t, cfgDefault(), Deps{Auth: jwt}) tok := signAny(t, jwt, "acct", auth.RoleOwner) resp, _ := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/system/rotate-jwt", map[string]any{"newSecret": " "}, tok) if resp.StatusCode != http.StatusBadRequest { t.Fatalf("status = %d, want 400", resp.StatusCode) } } // --- backup ------------------------------------------------------------------ func TestAdminBackupProducesFile(t *testing.T) { t.Parallel() dir := t.TempDir() dbPath := newFileDB(t, dir, "cyrene.db") _, ts, jwt := buildEnv(t, cfgDefault(), Deps{DatabasePath: dbPath}) tok := signAny(t, jwt, "acct", auth.RoleOwner) resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/system/backup", nil, tok) if resp.StatusCode != http.StatusOK { t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body) } var res struct { Path string `json:"path"` Size int64 `json:"size"` } if err := json.Unmarshal(decodeBody(t, body)["data"], &res); err != nil { t.Fatalf("decode: %v", err) } if !strings.HasPrefix(res.Path, dir) { t.Fatalf("backup path %q is not under data dir %q", res.Path, dir) } if res.Size <= 0 { t.Fatalf("backup size = %d, want > 0", res.Size) } if info, err := os.Stat(res.Path); err != nil || info.Size() != res.Size { t.Fatalf("backup file stat = (%v, %d), want no error and size %d", err, info.Size(), res.Size) } } func TestAdminBackupUnwired(t *testing.T) { t.Parallel() _, ts, jwt := buildEnv(t, cfgDefault(), Deps{}) // no DatabasePath tok := signAny(t, jwt, "acct", auth.RoleOwner) resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/system/backup", nil, tok) if resp.StatusCode != http.StatusInternalServerError { t.Fatalf("status = %d, want 500 (body=%s)", resp.StatusCode, body) } wantErrorShape(t, body) } // --- restore ----------------------------------------------------------------- func TestAdminRestoreReplacesAndPreBacksUp(t *testing.T) { t.Parallel() dir := t.TempDir() dbPath := newFileDB(t, dir, "cyrene.db") backupSrc := newFileDB(t, dir, "backup.db") // Make the backup source clearly different from the live db by appending a // marker. Restore only copies bytes and never opens the source, so the marker // survives into the replaced live file and proves the swap happened. srcFile, err := os.OpenFile(backupSrc, os.O_WRONLY|os.O_APPEND, 0o644) if err != nil { t.Fatalf("open backup source: %v", err) } if _, err := srcFile.WriteString("RESTORE-MARKER"); err != nil { t.Fatalf("append marker: %v", err) } if err := srcFile.Close(); err != nil { t.Fatalf("close backup source: %v", err) } liveBefore, err := os.ReadFile(dbPath) if err != nil { t.Fatalf("read live db before: %v", err) } _, ts, jwt := buildEnv(t, cfgDefault(), Deps{DatabasePath: dbPath}) tok := signAny(t, jwt, "acct", auth.RoleOwner) // No pre-existing backups yet. before, _ := filepath.Glob(filepath.Join(dir, "backup-*.db")) resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/system/restore", map[string]any{"path": backupSrc}, tok) if resp.StatusCode != http.StatusOK { t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body) } var res struct { Restored bool `json:"restored"` Backup string `json:"backup"` Note string `json:"note"` } if err := json.Unmarshal(decodeBody(t, body)["data"], &res); err != nil { t.Fatalf("decode: %v", err) } if !res.Restored || res.Note == "" { t.Fatalf("res = %+v, want restored with note", res) } // The current DB was snapshotted before the replace (a new backup appeared). after, _ := filepath.Glob(filepath.Join(dir, "backup-*.db")) if len(after) != len(before)+1 { t.Fatalf("backup count before=%d after=%d, want one more pre-restore snapshot", len(before), len(after)) } // The live DB was replaced with the backup source's bytes, so it now carries // the marker and differs from its pre-restore content. liveAfter, err := os.ReadFile(dbPath) if err != nil { t.Fatalf("read live db after: %v", err) } if !strings.Contains(string(liveAfter), "RESTORE-MARKER") { t.Fatalf("live db not replaced (marker missing); before len=%d after len=%d", len(liveBefore), len(liveAfter)) } if string(liveAfter) == string(liveBefore) { t.Fatal("live db unchanged after restore") } } func TestAdminRestoreRejectsTraversal(t *testing.T) { t.Parallel() dir := t.TempDir() // data dir outside := t.TempDir() // a sibling directory, outside the data dir dbPath := newFileDB(t, dir, "cyrene.db") outsideBackup := newFileDB(t, outside, "outside.db") _, ts, jwt := buildEnv(t, cfgDefault(), Deps{DatabasePath: dbPath}) tok := signAny(t, jwt, "acct", auth.RoleOwner) resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/system/restore", map[string]any{"path": outsideBackup}, tok) if resp.StatusCode != http.StatusBadRequest { t.Fatalf("status = %d, want 400 (body=%s)", resp.StatusCode, body) } wantErrorShape(t, body) } func TestAdminRestoreRejectsMissingFile(t *testing.T) { t.Parallel() dir := t.TempDir() dbPath := newFileDB(t, dir, "cyrene.db") _, ts, jwt := buildEnv(t, cfgDefault(), Deps{DatabasePath: dbPath}) tok := signAny(t, jwt, "acct", auth.RoleOwner) resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/system/restore", map[string]any{"path": filepath.Join(dir, "does-not-exist.db")}, tok) if resp.StatusCode != http.StatusNotFound { t.Fatalf("status = %d, want 404 (body=%s)", resp.StatusCode, body) } wantErrorShape(t, body) } func TestAdminRestoreUnwired(t *testing.T) { t.Parallel() _, ts, jwt := buildEnv(t, cfgDefault(), Deps{}) // no DatabasePath tok := signAny(t, jwt, "acct", auth.RoleOwner) resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/system/restore", map[string]any{"path": "x"}, tok) if resp.StatusCode != http.StatusInternalServerError { t.Fatalf("status = %d, want 500 (body=%s)", resp.StatusCode, body) } wantErrorShape(t, body) } func TestAdminRestoreRequiresPath(t *testing.T) { t.Parallel() dir := t.TempDir() dbPath := newFileDB(t, dir, "cyrene.db") _, ts, jwt := buildEnv(t, cfgDefault(), Deps{DatabasePath: dbPath}) tok := signAny(t, jwt, "acct", auth.RoleOwner) resp, _ := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/system/restore", map[string]any{"path": ""}, tok) if resp.StatusCode != http.StatusBadRequest { t.Fatalf("status = %d, want 400", resp.StatusCode) } }