m/paliad#77 Slice A. Folds the unbuilt t-paliad-214 Slice 3 (org async export) into a new "Backup Mode" surface gated by adminGate. m's calls (all 4 material picks per design §2): - Storage: local disk PALIAD_EXPORT_DIR (LocalDiskStore only) - Format: .zip bundle (xlsx + JSON + CSV + README) — no-lock-in preserved - paliadin_turns + paliadin_aichat_conversation: EXCLUDE structurally - Scheduler (Slice B): nightly 03:00 UTC, env-tunable Wiring: - mig 123 adds paliad.backups catalog table (kind/status/storage_uri/ size/row_counts/warnings/error/deleted_at + admin-only RLS). - ExportService.WriteOrg + orgSheetQueries enumerate 37 entity sheets + 12 ref sheets; REPEATABLE READ READ ONLY tx wraps the dump for snapshot consistency (design §3.3). - writeBundle + runSheetQuery refactored to take a sqlx.QueryerContext so both *sqlx.DB (personal/project paths, unchanged) and *sqlx.Tx (org snapshot path) work. - BackupRunner orchestrates: catalog INSERT → audit INSERT (event_type='backup_created') → WriteOrg → ArtifactStore.Put → patch catalog + audit on success/failure. - ArtifactStore interface + LocalDiskStore impl (defense-in-depth key validation + URI-outside-dir guard). - Sentinel actor for scheduled runs: actor_email='system@paliad', actor_id=NULL — no phantom user in paliad.users. - Admin handlers POST /api/admin/backups/run + GET list/get/download behind adminGate(users, …); /admin/backups page + sidebar entry + bilingual i18n keys. - BackupRunner only wired when PALIAD_EXPORT_DIR is set; routes return 503 otherwise (same shape as requireDB). Tests: 8 pure-function tests cover registry shape (no dups, paliadin absent both as sheet name and SQL substring, ref__* sheets unscoped, every sheet has ORDER BY) and LocalDiskStore (round-trip, bad-key rejection, URI-traversal rejection, mkdir on construction). go build ./... + go test ./internal/... clean. bun run build clean. Slice B (BackupScheduler + retention cleanup) and Slice C (UI polish) are separate follow-ups per head's instruction.
194 lines
6.1 KiB
Go
194 lines
6.1 KiB
Go
package services
|
|
|
|
// Pure-function tests for the Backup Mode runtime (t-paliad-246 / m/paliad#77).
|
|
//
|
|
// Live DB behaviour (the actual org dump end-to-end) needs a Postgres;
|
|
// it would live in backup_service_live_test.go under TEST_DATABASE_URL.
|
|
// This file covers the bits that don't need a database:
|
|
//
|
|
// - orgSheetQueries registry shape: no duplicates, no excluded
|
|
// paliadin sheets, predictable prefix split between entity and ref.
|
|
// - LocalDiskStore Put / Get / Delete round-trip, key validation,
|
|
// URI traversal rejection.
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// orgSheetQueries registry
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestOrgSheetQueries_NoDuplicates(t *testing.T) {
|
|
seen := map[string]bool{}
|
|
for _, sq := range orgSheetQueries() {
|
|
if seen[sq.SheetName] {
|
|
t.Fatalf("duplicate sheet name in orgSheetQueries: %q", sq.SheetName)
|
|
}
|
|
seen[sq.SheetName] = true
|
|
}
|
|
}
|
|
|
|
func TestOrgSheetQueries_ExcludesPaliadinTables(t *testing.T) {
|
|
// m's t-paliad-214 Q5 decision + this design's §11 Q3 default:
|
|
// paliadin_turns and paliadin_aichat_conversation must be ABSENT
|
|
// from the registry (structural exclusion, not just column-drop).
|
|
for _, sq := range orgSheetQueries() {
|
|
name := sq.SheetName
|
|
if strings.Contains(name, "paliadin") {
|
|
t.Fatalf("orgSheetQueries leaked paliadin sheet: %q (m's Q3 mandates structural exclusion)", name)
|
|
}
|
|
// Belt-and-braces: SQL bodies should not reference the tables
|
|
// either (no UNION joins, no subqueries pulling them in).
|
|
if strings.Contains(sq.SQL, "paliadin_turns") || strings.Contains(sq.SQL, "paliadin_aichat_conversation") {
|
|
t.Fatalf("orgSheetQueries[%q] SQL references a paliadin table: %s", name, sq.SQL)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestOrgSheetQueries_RefSheetsPrefixed(t *testing.T) {
|
|
// Every sheet whose data is read-only reference material is
|
|
// expected to use the `ref__` prefix. The writer's downstream
|
|
// consumers rely on this convention to group reference data
|
|
// visually in the workbook.
|
|
for _, sq := range orgSheetQueries() {
|
|
if !strings.HasPrefix(sq.SheetName, "ref__") {
|
|
continue
|
|
}
|
|
// Reference sheets shouldn't carry per-row WHERE clauses (they
|
|
// dump the whole reference table for portability).
|
|
if strings.Contains(strings.ToUpper(sq.SQL), "WHERE") {
|
|
t.Fatalf("orgSheetQueries[%q] is ref__ but has a WHERE clause; reference sheets dump the whole table", sq.SheetName)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestOrgSheetQueries_OrderByForDeterminism(t *testing.T) {
|
|
// Every sheet must specify an ORDER BY so the byte-deterministic
|
|
// contract from t-paliad-214 §3 holds across runs.
|
|
for _, sq := range orgSheetQueries() {
|
|
if !strings.Contains(strings.ToUpper(sq.SQL), "ORDER BY") {
|
|
t.Fatalf("orgSheetQueries[%q] missing ORDER BY (determinism contract): %s", sq.SheetName, sq.SQL)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// LocalDiskStore round-trip
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestLocalDiskStore_RoundTrip(t *testing.T) {
|
|
dir := t.TempDir()
|
|
store, err := NewLocalDiskStore(dir)
|
|
if err != nil {
|
|
t.Fatalf("NewLocalDiskStore: %v", err)
|
|
}
|
|
ctx := context.Background()
|
|
want := []byte("hello backup\n")
|
|
|
|
uri, err := store.Put(ctx, "test.zip", want)
|
|
if err != nil {
|
|
t.Fatalf("Put: %v", err)
|
|
}
|
|
if !strings.HasPrefix(uri, "file://") {
|
|
t.Fatalf("expected file:// uri, got %q", uri)
|
|
}
|
|
rc, size, err := store.Get(ctx, uri)
|
|
if err != nil {
|
|
t.Fatalf("Get: %v", err)
|
|
}
|
|
defer rc.Close()
|
|
if size != int64(len(want)) {
|
|
t.Fatalf("Get size = %d, want %d", size, len(want))
|
|
}
|
|
got, err := io.ReadAll(rc)
|
|
if err != nil {
|
|
t.Fatalf("ReadAll: %v", err)
|
|
}
|
|
if !bytes.Equal(got, want) {
|
|
t.Fatalf("Get body = %q, want %q", got, want)
|
|
}
|
|
if err := store.Delete(ctx, uri); err != nil {
|
|
t.Fatalf("Delete: %v", err)
|
|
}
|
|
// File should be gone; Get returns an error.
|
|
if _, _, err := store.Get(ctx, uri); err == nil {
|
|
t.Fatalf("Get after Delete should fail")
|
|
}
|
|
// Delete is idempotent.
|
|
if err := store.Delete(ctx, uri); err != nil {
|
|
t.Fatalf("idempotent Delete: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestLocalDiskStore_RejectsBadKeys(t *testing.T) {
|
|
dir := t.TempDir()
|
|
store, err := NewLocalDiskStore(dir)
|
|
if err != nil {
|
|
t.Fatalf("NewLocalDiskStore: %v", err)
|
|
}
|
|
ctx := context.Background()
|
|
cases := []string{
|
|
"",
|
|
"sub/dir/file.zip",
|
|
"..\\evil.zip",
|
|
"../escape.zip",
|
|
"/abs/path.zip",
|
|
}
|
|
for _, k := range cases {
|
|
if _, err := store.Put(ctx, k, []byte("x")); err == nil {
|
|
t.Fatalf("Put with bad key %q should fail", k)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestLocalDiskStore_RejectsURIOutsideDir(t *testing.T) {
|
|
dir := t.TempDir()
|
|
store, err := NewLocalDiskStore(dir)
|
|
if err != nil {
|
|
t.Fatalf("NewLocalDiskStore: %v", err)
|
|
}
|
|
ctx := context.Background()
|
|
// A file:// URI pointing outside the store dir must be rejected
|
|
// by both Get and Delete (defense in depth against a corrupted
|
|
// catalog row).
|
|
outside := "file://" + filepath.Join(filepath.Dir(dir), "elsewhere.zip")
|
|
if _, _, err := store.Get(ctx, outside); err == nil {
|
|
t.Fatalf("Get outside store dir should fail")
|
|
}
|
|
if err := store.Delete(ctx, outside); err == nil {
|
|
t.Fatalf("Delete outside store dir should fail")
|
|
}
|
|
// Wrong scheme is also rejected.
|
|
if _, _, err := store.Get(ctx, "https://example.com/foo.zip"); err == nil {
|
|
t.Fatalf("Get with non-file:// scheme should fail")
|
|
}
|
|
}
|
|
|
|
func TestLocalDiskStore_CreatesDir(t *testing.T) {
|
|
// A non-existent parent gets created at construction; mode 0700.
|
|
base := t.TempDir()
|
|
target := filepath.Join(base, "nested", "exports")
|
|
store, err := NewLocalDiskStore(target)
|
|
if err != nil {
|
|
t.Fatalf("NewLocalDiskStore(non-existent): %v", err)
|
|
}
|
|
info, err := os.Stat(target)
|
|
if err != nil {
|
|
t.Fatalf("expected store dir to exist: %v", err)
|
|
}
|
|
if !info.IsDir() {
|
|
t.Fatalf("expected directory, got file")
|
|
}
|
|
// Smoke-write to confirm the dir is actually usable.
|
|
if _, err := store.Put(context.Background(), "ok.zip", []byte{}); err != nil {
|
|
t.Fatalf("Put into fresh dir: %v", err)
|
|
}
|
|
}
|