Files
Alphaeus Mote 63701dd086 feat: real restore, portable secret key, multi-arch image, real CSRF
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>
2026-09-03 13:57:15 -04:00

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