63701dd086
Addresses the gaps identified in the last audit. Restore (was a stub returning "not yet implemented"). Every repository shares one connection pool, so the database cannot be swapped underneath a live server. Restore is therefore two-phase: RestoreBackup validates the file and stages it beside the database; db.New applies it before the pool is opened, which is the only safe moment. The database being replaced is preserved as <db>.replaced-<timestamp>, and stale -wal/-shm are removed so SQLite cannot replay the old journal over the restored file. Validation is strict — SQLite integrity_check plus a schema probe — because applying an unrelated file would destroy the install. GET/DELETE /api/v1/backups/restore inspect and cancel a staged restore. The CLI does both phases at once, since it runs standalone; `orchestrad backup` was also a stub and now works. Secret key. With nothing configured the key is generated once and persisted to <data>/secret.key, so restarts reuse it and moving the stack to another server is a matter of copying the data directory. Upgrades are handled: if a database already exists the install was silently running on the legacy built-in default, so that value is adopted and written out rather than replaced — generating a fresh key there would make every stored credential undecryptable. The file is owner-only (ACL-restricted on Windows). Multi-arch image: buildx now emits linux/amd64 + linux/arm64, matching the architectures the release binaries already covered. The Dockerfile cross-compiles via TARGETARCH rather than emulating, so arm64 costs little. CSRF: the middleware previously checked only that a header was *present* and was never wired up, and /auth/csrf returned "csrf-token-placeholder". Tokens are now nonce + HMAC-SHA256 signed with the application secret, validated properly, and the middleware is mounted on /api/v1. Bearer and API-key requests are not CSRF-reachable and pass through untouched, so this is transparent to the SPA and to API clients. Also: the Windows store import drops CRYPT_EXPORTABLE (the store copy is not the source of truth — <data>/tls holds the key, so portability is unaffected and a non-exportable server key is the better posture), the PFX password is written to server.pfx.password beside the bundle so an operator importing it by hand does not have to hunt for a password they never chose, and the "renewed" log line now reflects whether a leaf was actually issued instead of guessing from its age. Verified live: backup -> stage -> restart applies and preserves the previous database; secret key generated, adopted, and read back across restarts with the credential check confirming decryptability; CSRF endpoint issues real signed tokens. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
171 lines
5.1 KiB
Go
171 lines
5.1 KiB
Go
package db
|
|
|
|
import (
|
|
"database/sql"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/Grace-Solutions/OrchestrAD/internal/config"
|
|
"github.com/Grace-Solutions/OrchestrAD/internal/logging"
|
|
)
|
|
|
|
func newTestDB(t *testing.T, path string) *DB {
|
|
t.Helper()
|
|
database, err := New(config.DatabaseConfig{
|
|
Path: path, MaxOpenConns: 2, MaxIdleConns: 2, WALMode: true, ForeignKeys: true,
|
|
}, logging.Default())
|
|
if err != nil {
|
|
t.Fatalf("db.New: %v", err)
|
|
}
|
|
if err := database.Migrate(); err != nil {
|
|
t.Fatalf("migrate: %v", err)
|
|
}
|
|
return database
|
|
}
|
|
|
|
// TestRestoreRoundTrip covers the full restore contract: a backup taken at one
|
|
// point in time is staged, applied on the next open, and the data it held comes
|
|
// back while the replaced database is preserved.
|
|
func TestRestoreRoundTrip(t *testing.T) {
|
|
dir := t.TempDir()
|
|
dbPath := filepath.Join(dir, "db", "orchestrad.db")
|
|
backupPath := filepath.Join(dir, "backup.db")
|
|
|
|
database := newTestDB(t, dbPath)
|
|
if _, err := database.Conn().Exec(
|
|
`INSERT INTO users (id, username, is_active, created_utc, updated_utc)
|
|
VALUES ('u1','before-backup',1,'2026-01-01T00:00:00Z','2026-01-01T00:00:00Z')`); err != nil {
|
|
t.Fatalf("insert: %v", err)
|
|
}
|
|
if err := database.Backup(backupPath); err != nil {
|
|
t.Fatalf("Backup: %v", err)
|
|
}
|
|
|
|
// Change the live database after the backup, so a successful restore is
|
|
// observable: this row must be gone afterwards.
|
|
if _, err := database.Conn().Exec(
|
|
`INSERT INTO users (id, username, is_active, created_utc, updated_utc)
|
|
VALUES ('u2','after-backup',1,'2026-01-02T00:00:00Z','2026-01-02T00:00:00Z')`); err != nil {
|
|
t.Fatalf("insert: %v", err)
|
|
}
|
|
database.Close()
|
|
|
|
if err := StageRestore(dbPath, backupPath); err != nil {
|
|
t.Fatalf("StageRestore: %v", err)
|
|
}
|
|
if PendingRestorePath(dbPath) == "" {
|
|
t.Fatal("expected a pending restore after staging")
|
|
}
|
|
|
|
// Reopening applies it.
|
|
restored := newTestDB(t, dbPath)
|
|
defer restored.Close()
|
|
|
|
if PendingRestorePath(dbPath) != "" {
|
|
t.Error("pending restore should be consumed once applied")
|
|
}
|
|
var names []string
|
|
rows, err := restored.Conn().Query(`SELECT username FROM users ORDER BY username`)
|
|
if err != nil {
|
|
t.Fatalf("query: %v", err)
|
|
}
|
|
defer rows.Close()
|
|
for rows.Next() {
|
|
var n string
|
|
if err := rows.Scan(&n); err != nil {
|
|
t.Fatalf("scan: %v", err)
|
|
}
|
|
names = append(names, n)
|
|
}
|
|
joined := strings.Join(names, ",")
|
|
if !strings.Contains(joined, "before-backup") {
|
|
t.Errorf("restored database missing the backed-up row; got %q", joined)
|
|
}
|
|
if strings.Contains(joined, "after-backup") {
|
|
t.Errorf("restored database still has the post-backup row; restore did not apply (got %q)", joined)
|
|
}
|
|
|
|
// The replaced database is kept, so a mistaken restore is recoverable.
|
|
entries, _ := os.ReadDir(filepath.Dir(dbPath))
|
|
found := false
|
|
for _, e := range entries {
|
|
if strings.Contains(e.Name(), ".replaced-") {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Error("expected the replaced database to be preserved alongside")
|
|
}
|
|
}
|
|
|
|
// TestValidateBackupRejectsJunk guards the check that stops an unrelated or
|
|
// corrupt file from destroying an install.
|
|
func TestValidateBackupRejectsJunk(t *testing.T) {
|
|
dir := t.TempDir()
|
|
|
|
missing := filepath.Join(dir, "nope.db")
|
|
if err := ValidateBackup(missing); err == nil {
|
|
t.Error("missing file should not validate")
|
|
}
|
|
|
|
empty := filepath.Join(dir, "empty.db")
|
|
if err := os.WriteFile(empty, nil, 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := ValidateBackup(empty); err == nil {
|
|
t.Error("empty file should not validate")
|
|
}
|
|
|
|
garbage := filepath.Join(dir, "garbage.db")
|
|
if err := os.WriteFile(garbage, []byte("this is definitely not sqlite"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := ValidateBackup(garbage); err == nil {
|
|
t.Error("non-sqlite file should not validate")
|
|
}
|
|
|
|
// A valid SQLite database that is not an OrchestrAD one must also be
|
|
// refused — this is the case most likely to be an operator mistake.
|
|
foreign := filepath.Join(dir, "foreign.db")
|
|
conn, err := sql.Open("sqlite", foreign)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := conn.Exec(`CREATE TABLE something (id INTEGER)`); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
conn.Close()
|
|
if err := ValidateBackup(foreign); err == nil {
|
|
t.Error("a foreign SQLite database should not validate as an OrchestrAD backup")
|
|
}
|
|
}
|
|
|
|
// TestCancelPendingRestore: a staged restore can be called off before restart.
|
|
func TestCancelPendingRestore(t *testing.T) {
|
|
dir := t.TempDir()
|
|
dbPath := filepath.Join(dir, "db", "orchestrad.db")
|
|
backupPath := filepath.Join(dir, "backup.db")
|
|
|
|
database := newTestDB(t, dbPath)
|
|
if err := database.Backup(backupPath); err != nil {
|
|
t.Fatalf("Backup: %v", err)
|
|
}
|
|
database.Close()
|
|
|
|
if err := StageRestore(dbPath, backupPath); err != nil {
|
|
t.Fatalf("StageRestore: %v", err)
|
|
}
|
|
if err := CancelPendingRestore(dbPath); err != nil {
|
|
t.Fatalf("CancelPendingRestore: %v", err)
|
|
}
|
|
if PendingRestorePath(dbPath) != "" {
|
|
t.Error("restore should no longer be pending after cancelling")
|
|
}
|
|
// Cancelling again is a no-op, not an error.
|
|
if err := CancelPendingRestore(dbPath); err != nil {
|
|
t.Errorf("second cancel should be a no-op: %v", err)
|
|
}
|
|
}
|