diff --git a/change-management/001_admin-finance-cleanup/assets/cleanup.sql b/change-management/001_admin-finance-cleanup_completed/assets/cleanup.sql similarity index 100% rename from change-management/001_admin-finance-cleanup/assets/cleanup.sql rename to change-management/001_admin-finance-cleanup_completed/assets/cleanup.sql diff --git a/change-management/001_admin-finance-cleanup/assets/dry-run.sql b/change-management/001_admin-finance-cleanup_completed/assets/dry-run.sql similarity index 100% rename from change-management/001_admin-finance-cleanup/assets/dry-run.sql rename to change-management/001_admin-finance-cleanup_completed/assets/dry-run.sql diff --git a/change-management/001_admin-finance-cleanup/description.md b/change-management/001_admin-finance-cleanup_completed/description.md similarity index 100% rename from change-management/001_admin-finance-cleanup/description.md rename to change-management/001_admin-finance-cleanup_completed/description.md diff --git a/change-management/001_admin-finance-cleanup_completed/execution-log.md b/change-management/001_admin-finance-cleanup_completed/execution-log.md new file mode 100644 index 0000000..7496a54 --- /dev/null +++ b/change-management/001_admin-finance-cleanup_completed/execution-log.md @@ -0,0 +1,248 @@ +# Execution Log: 001_admin-finance-cleanup + +- Technician: thompson +- Date/Time started: 2026-07-06 19:27:51 BST +- Environment: prod + +## Preflight +### Activity 1: Merge the operator-only backend PR to main +- [x] Confirm the operator-only backend PR is approved and up to date with `main`. +- [x] Merge the PR to `main`. +- [x] Note the merge commit SHA for the deploy and post-run SHA check. +**Validation 1: CI is green on main** +- [x] The `main` CI run for the merge commit is green (build, tests, lint). +- [x] The `validate-change-management` job passed. +> Comments: PR #35 merged to main at 2026-07-06T08:28:03Z. Merge commit: cc5c2801eaa29214af66f09d4c4ef89c3348c33e. CI green per handoff/user report. + +```text +$ gh pr view 35 --repo ItsThompson/gofin --json state,mergedAt,mergeCommit,headRefName,baseRefName,title,url +{"baseRefName":"main","headRefName":"refactor/admin-account","mergeCommit":{"oid":"cc5c2801eaa29214af66f09d4c4ef89c3348c33e"},"mergedAt":"2026-07-06T08:28:03Z","state":"MERGED","title":"refactor(admin): operator-only admin + change-management framework","url":"https://github.com/ItsThompson/gofin/pull/35"} + +$ python3 change-management/.validation/validate_change_management.py +OK: 2 change-management item(s) conform. +``` + +### Activity 2: Deploy the merged code to prod +- [x] Run the deploy (CD on push to `main`, or `scripts/deploy.sh `). +- [x] Confirm the deployed SHA matches the merge commit from Activity 1 +**Validation 2: Prod services healthy and app reachable** +- [x] On the VPS, `cd /opt/gofin && docker compose ps` shows all services healthy. +- [x] The app responds over its public URL (login page loads). +> Comments: User confirmed deployed SHA, service health, and public login page. + +```text +root@gofin:/opt/gofin# cat .deployed-sha +cc5c2801eaa29214af66f09d4c4ef89c3348c33e + +root@gofin:/opt/gofin# docker compose ps +NAME IMAGE COMMAND SERVICE CREATED STATUS PORTS +gofin-alertmanager-1 ghcr.io/itsthompson/gofin/alertmanager:latest "/entrypoint.sh --co…" alertmanager 10 hours ago Up 10 hours 0.0.0.0:9093->9093/tcp, [::]:9093->9093/tcp +gofin-api-gateway-1 ghcr.io/itsthompson/gofin/api-gateway:latest "/service" api-gateway 10 hours ago Up 10 hours (healthy) 0.0.0.0:8080->8080/tcp, [::]:8080->8080/tcp +gofin-auth-service-1 ghcr.io/itsthompson/gofin/auth-service:latest "/service" auth-service 10 hours ago Up 10 hours (healthy) 8081/tcp, 9081/tcp +gofin-cadvisor-1 gcr.io/cadvisor/cadvisor:v0.55.1 "/usr/bin/cadvisor -…" cadvisor 8 weeks ago Up 8 weeks (healthy) 8080/tcp +gofin-cloudflared-app-1 cloudflare/cloudflared:2025.4.0 "cloudflared --no-au…" cloudflared-app 7 weeks ago Up 7 weeks +gofin-cloudflared-grafana-1 cloudflare/cloudflared:2025.4.0 "cloudflared --no-au…" cloudflared-grafana 7 weeks ago Up 7 weeks +gofin-datarights-service-1 ghcr.io/itsthompson/gofin/datarights-service:latest "/service" datarights-service 10 hours ago Up 10 hours (healthy) 8084/tcp, 9084/tcp +gofin-expense-service-1 ghcr.io/itsthompson/gofin/expense-service:latest "/service" expense-service 10 hours ago Up 10 hours (healthy) 8082/tcp, 9082/tcp +gofin-finance-service-1 ghcr.io/itsthompson/gofin/finance-service:latest "/service" finance-service 10 hours ago Up 10 hours (healthy) 8083/tcp, 9083/tcp +gofin-grafana-1 grafana/grafana:11.6.0 "/run.sh" grafana 7 weeks ago Up 7 weeks 0.0.0.0:3001->3000/tcp, [::]:3001->3000/tcp +gofin-grafana-auth-proxy-1 ghcr.io/itsthompson/gofin/grafana-auth-proxy:latest "/service" grafana-auth-proxy 10 hours ago Up 10 hours 0.0.0.0:3002->3002/tcp, [::]:3002->3002/tcp +gofin-immudb-1 codenotary/immudb:1.11.0 "/usr/sbin/immudb" immudb 7 weeks ago Up 4 weeks (healthy) 5432/tcp, 8080/tcp, 0.0.0.0:3322->3322/tcp, [::]:3322->3322/tcp, 9497/tcp +gofin-mfe-1 ghcr.io/itsthompson/gofin/mfe:latest "docker-entrypoint.s…" mfe 10 hours ago Up 10 hours 0.0.0.0:3000->3000/tcp, [::]:3000->3000/tcp +gofin-node-exporter-1 prom/node-exporter:v1.11.1 "/bin/node_exporter …" node-exporter 8 weeks ago Up 8 weeks 9100/tcp +gofin-postgresql-1 postgres:16.8-alpine "docker-entrypoint.s…" postgresql 4 weeks ago Up 4 weeks (healthy) 5432/tcp +gofin-prometheus-1 prom/prometheus:v3.11.3 "/bin/prometheus --c…" prometheus 7 weeks ago Up 7 weeks 0.0.0.0:9090->9090/tcp, [::]:9090->9090/tcp +``` + +User confirmed `usegofin.com/login` loads. + +### Activity 3: Run the read-only dry-run pre-check on prod +- [x] On the VPS at `/opt/gofin`, run: +- [x] Record the per-table counts for comparison against the deletion in `steps.md`. +**Validation 3: Admin-owned counts match expectation** +- [x] `finance.budget_periods` reports approximately 1 admin-owned row. +- [x] `finance.pro_rata_schedules`, `finance.tags`, `finance.default_settings`, and +> Comments: Dry-run output and follow-up read-only tag inspection. + +```text +root@gofin:/opt/gofin# docker compose exec -T postgresql psql -U gofin -d gofin < change-management/001_admin-finance-cleanup/assets/dry-run.sql +WARNING: database "gofin" has no actual collation version, but a version was recorded + table_name | count +----------------------------+------- + finance.pro_rata_schedules | 0 + finance.tags | 10 + finance.budget_periods | 1 + finance.default_settings | 1 + datarights.export_jobs | 0 +(5 rows) + +root@gofin:/opt/gofin# docker compose exec -T postgresql psql -U gofin -d gofin -c " + SELECT t.name, t.is_default + FROM finance.tags t + JOIN auth.users u ON u.id = t.user_id + WHERE u.role = 'admin' + ORDER BY t.name; + " +WARNING: database "gofin" has no actual collation version, but a version was recorded + name | is_default +--------------------------+------------ + Bills | t + Food | t + Household | t + Investment | t + Personal Care | t + Recreation/Entertainment | t + Self Investment | t + Social | t + Transport | t + Travel | t +(10 rows) +``` + +User accepted `finance.tags=10` and `finance.default_settings=1` as expected admin onboarding/default-tag residue. Repo-only subagent review corroborated this: onboarding creates one default settings row and 10 default tags, and the deployed refactor blocks new direct-admin finance rows. + +## Steps +### Activity 1: SSH to the VPS and enter the repo +- [x] SSH to the production VPS. +- [x] `cd /opt/gofin`. +**Validation 1: Correct host and repo SHA** +- [x] `hostname` (or the cloud console) confirms the intended production host. +- [x] `cat /opt/gofin/.deployed-sha` matches the merge commit SHA from preflight +> Comments: SSH prompt/output shows the production host and repo path, with deployed SHA matching the merge commit. + +```text +root@gofin:/opt/gofin# cat .deployed-sha +cc5c2801eaa29214af66f09d4c4ef89c3348c33e +``` + +### Activity 2: Take a database backup/snapshot +- [x] At `/opt/gofin`, dump the database, e.g.: +- [x] Record the backup artifact path and size. +**Validation 2: Backup artifact exists and is restorable** +- [x] The dump file exists and is non-empty. +- [x] `pg_restore -l ` (or a restore into a scratch database) lists the +> Comments: Backup created and validated using container `pg_restore` because host `pg_restore` was not installed. + +```text +root@gofin:/opt/gofin# mkdir -p backups && docker compose exec -T postgresql pg_dump -U gofin -Fc gofin > backups/gofin-pre-001-$(date +%Y%m%d%H%M%S).dump +WARNING: database "gofin" has no actual collation version, but a version was recorded + +root@gofin:/opt/gofin# ls -lh backups/gofin-pre-001-*.dump | tail -1 +-rw-r--r-- 1 root root 24K Jul 6 18:36 backups/gofin-pre-001-20260706183601.dump + +root@gofin:/opt/gofin# pg_restore -l backups/gofin-pre-001-20260706183601.dump | head +Command 'pg_restore' not found, but can be installed with: +apt install postgresql-client-common + +root@gofin:/opt/gofin# ls -lh backups/gofin-pre-001-20260706183601.dump +-rw-r--r-- 1 root root 24K Jul 6 18:36 backups/gofin-pre-001-20260706183601.dump + +root@gofin:/opt/gofin# docker compose exec -T postgresql pg_restore -l < backups/gofin-pre-001-20260706183601.dump >/dev/null && echo "pg_restore list OK" +pg_restore list OK + +root@gofin:/opt/gofin# docker compose exec -T postgresql pg_restore -l < backups/gofin-pre-001-20260706183601.dump | head -20 +; +; Archive created at 2026-07-06 18:36:01 UTC +; dbname: gofin +; TOC Entries: 57 +; Compression: gzip +; Dump Version: 1.15-0 +; Format: CUSTOM +; Integer: 4 bytes +; Offset: 8 bytes +; Dumped from database version: 16.8 +; Dumped by pg_dump version: 16.8 +; +; +; Selected TOC Entries: +; +6; 2615 16385 SCHEMA - auth gofin +8; 2615 16478 SCHEMA - datarights gofin +7; 2615 16386 SCHEMA - finance gofin +219; 1259 16407 TABLE auth refresh_token_blacklist gofin +226; 1259 16507 TABLE auth schema_migrations gofin +``` + +### Activity 3: Run cleanup.sql in a transaction +- [x] At `/opt/gofin`, run: +- [x] Capture the per-statement `DELETE ` row counts and the final `COMMIT`. +**Validation 3: Deleted-row counts match the dry-run** +- [x] The `DELETE` counts per table equal the preflight dry-run counts (e.g. +- [x] The transaction ended with `COMMIT` (no rollback/error). +> Comments: Cleanup completed with `COMMIT`. DELETE counts matched dry-run in cleanup SQL order: `finance.pro_rata_schedules=0`, `finance.tags=10`, `finance.budget_periods=1`, `finance.default_settings=1`, `datarights.export_jobs=0`. + +```text +root@gofin:/opt/gofin# docker compose exec -T postgresql psql -U gofin -d gofin < change-management/001_admin-finance-cleanup/assets/cleanup.sql +WARNING: database "gofin" has no actual collation version, but a version was recorded +BEGIN +DELETE 0 +DELETE 10 +DELETE 1 +DELETE 1 +DELETE 0 +COMMIT +``` + +### Activity 4: Post-check by re-running the dry-run +- [x] At `/opt/gofin`, run: +- [x] Spot-check that `auth.users` admin rows and `datarights.deletion_jobs` audit +**Validation 4: Zero admin-owned rows; audit data intact** +- [x] The dry-run reports 0 admin-owned rows for all five target tables. +- [x] `auth.users` admin (operator) rows and `datarights.deletion_jobs` audit rows +> Comments: Post-check dry-run returned zero for all cleanup targets. Spot-check found one admin user remains. `datarights.deletion_jobs` count was zero; no audit rows were present to preserve, and `cleanup.sql` does not target that table. + +```text +root@gofin:/opt/gofin# docker compose exec -T postgresql psql -U gofin -d gofin < change-management/001_admin-finance-cleanup/assets/dry-run.sql +WARNING: database "gofin" has no actual collation version, but a version was recorded + table_name | count +----------------------------+------- + finance.pro_rata_schedules | 0 + finance.tags | 0 + finance.budget_periods | 0 + finance.default_settings | 0 + datarights.export_jobs | 0 +(5 rows) + +root@gofin:/opt/gofin# docker compose exec -T postgresql psql -U gofin -d gofin -c " + SELECT 'auth.admin_users' AS check_name, count(*) FROM auth.users WHERE role = 'admin' + UNION ALL + SELECT 'datarights.deletion_jobs', count(*) FROM datarights.deletion_jobs; + " +WARNING: database "gofin" has no actual collation version, but a version was recorded + check_name | count +--------------------------+------- + auth.admin_users | 1 + datarights.deletion_jobs | 0 +(2 rows) +``` + +### Activity 5: Finalize via the completion PR +- [x] Generate and fill in `execution-log.md` +- [x] Remove the temporary safety test +- [x] `git mv change-management/001_admin-finance-cleanup change-management/001_admin-finance-cleanup_completed`. +- [x] Open the completion PR with the rename and the filled `execution-log.md`. +**Validation 5: CI green including validate-change-management** +- [x] CI on the completion PR is green. +- [x] The `validate-change-management` job passes for +> Comments: Repo finalization prepared on branch `chore/complete-001-cleanup`: execution log filled, temporary safety test removed, folder renamed with `_completed` status. Local validation passed. + +```text +$ python3 change-management/.validation/validate_change_management.py +OK: 2 change-management item(s) conform. + +$ cd services/dbmigrate && go test ./... +ok github.com/ItsThompson/gofin/services/dbmigrate 0.269s +``` + +No `docs/data-model.md` link to `001_admin-finance-cleanup/description.md` was present in the current tree, so no doc-link update was needed. Completion PR opened: https://github.com/ItsThompson/gofin/pull/36. PR checks passed, including `validate-change-management`. + +```text +$ gh pr checks 36 --repo ItsThompson/gofin --watch +All checks were successful +0 cancelled, 0 failing, 6 successful, 0 skipped, and 0 pending checks +``` + +## Outcome +- [x] Production cleanup activities and validations completed +- Status: completed +> Notes: Cleanup completed as written. Post-check returned zero admin-owned rows for all five target tables. User also verified in production that the admin account and a regular user account are working. Completion PR will rename the folder to `change-management/001_admin-finance-cleanup_completed`, remove the temporary safety test, and rely on CI to re-validate the completed change-management item. diff --git a/change-management/001_admin-finance-cleanup/preflight.md b/change-management/001_admin-finance-cleanup_completed/preflight.md similarity index 100% rename from change-management/001_admin-finance-cleanup/preflight.md rename to change-management/001_admin-finance-cleanup_completed/preflight.md diff --git a/change-management/001_admin-finance-cleanup/steps.md b/change-management/001_admin-finance-cleanup_completed/steps.md similarity index 100% rename from change-management/001_admin-finance-cleanup/steps.md rename to change-management/001_admin-finance-cleanup_completed/steps.md diff --git a/services/dbmigrate/admin_finance_cleanup_test.go b/services/dbmigrate/admin_finance_cleanup_test.go deleted file mode 100644 index f4d369b..0000000 --- a/services/dbmigrate/admin_finance_cleanup_test.go +++ /dev/null @@ -1,307 +0,0 @@ -package dbmigrate_test - -import ( - "database/sql" - "fmt" - "net/url" - "os" - "reflect" - "testing" - "time" - - _ "github.com/lib/pq" - - "github.com/ItsThompson/gofin/services/dbmigrate" -) - -// TestAdminFinanceCleanup proves that the shipping cleanup.sql is correctly -// scoped and idempotent before it is ever run on prod. It is TEMPORARY: the -// completion PR for change-management item 001 removes this file once the -// operation has run (the SQL and markdown remain as the permanent record). -// -// The test is gated on TEST_DATABASE_URL and skips when unset, matching the -// other gated dbmigrate tests. Run it locally against a disposable Postgres: -// -// TEST_DATABASE_URL='postgres://gofin:gofin@localhost:5432/gofin?sslmode=disable' \ -// go test ./dbmigrate/ -run TestAdminFinanceCleanup - -// cleanupSQLPath points at the exact file that ships in the 001 item, relative -// to services/dbmigrate/, so the test exercises the shipping asset itself. -const cleanupSQLPath = "../../change-management/001_admin-finance-cleanup/assets/cleanup.sql" - -// targetTables are the five tables cleanup.sql deletes admin-owned rows from. -// Every one has a user_id column, so counts can be scoped by owner. -var targetTables = []string{ - "finance.pro_rata_schedules", - "finance.tags", - "finance.budget_periods", - "finance.default_settings", - "datarights.export_jobs", -} - -// serviceMigration describes one service's migration run against the shared test -// database: its canonical migrations dir (relative to services/dbmigrate/), the -// schema it owns, and a distinct golang-migrate bookkeeping table so the three -// services' schema_migrations do not collide, mirroring prod's per-service -// search_path model. -type serviceMigration struct { - dir string - searchPath string - migrationsTable string -} - -var serviceMigrations = []serviceMigration{ - {dir: "../auth/db/migrations", searchPath: "auth", migrationsTable: "schema_migrations_auth"}, - {dir: "../finance/db/migrations", searchPath: "finance", migrationsTable: "schema_migrations_finance"}, - {dir: "../datarights/db/migrations", searchPath: "datarights", migrationsTable: "schema_migrations_datarights"}, -} - -// wantUserRows is the seeded finance-row count per table for one regular user; -// none of these may be touched by cleanup.sql. -var wantUserRows = map[string]int{ - "finance.pro_rata_schedules": 1, - "finance.tags": 2, - "finance.budget_periods": 1, - "finance.default_settings": 1, - "datarights.export_jobs": 1, -} - -func TestAdminFinanceCleanup(t *testing.T) { - baseURL := os.Getenv("TEST_DATABASE_URL") - if baseURL == "" { - t.Skip("TEST_DATABASE_URL not set: skipping integration test") - } - - runServiceMigrations(t, baseURL) - db := openDB(t, baseURL) - - adminID := seedUser(t, db, "cleanup_admin", "admin") - userID := seedUser(t, db, "cleanup_user", "user") - seedFinanceRows(t, db, adminID) - seedFinanceRows(t, db, userID) - auditID := seedDeletionJob(t, db, userID, adminID) - - // Pre-cleanup sanity: the admin owns rows in every target table. - for _, table := range targetTables { - if countByUser(t, db, table, adminID) == 0 { - t.Fatalf("seed check failed: admin owns no rows in %s", table) - } - } - - runCleanupSQL(t, db) - - // Scoping: every admin-owned row is gone from the five target tables. - for _, table := range targetTables { - if got := countByUser(t, db, table, adminID); got != 0 { - t.Errorf("admin rows not deleted from %s: got %d, want 0", table, got) - } - } - - // Blast radius: regular-user finance rows are untouched. - for table, want := range wantUserRows { - if got := countByUser(t, db, table, userID); got != want { - t.Errorf("regular-user rows in %s changed: got %d, want %d", table, got, want) - } - } - - // The admin identity itself must survive (operator accounts must remain). - if got := queryCount(t, db, - "SELECT count(*) FROM auth.users WHERE id = $1 AND role = 'admin'", adminID); got != 1 { - t.Errorf("auth.users admin row was modified: got %d, want 1", got) - } - - // Audit data in datarights.deletion_jobs must survive. - if got := queryCount(t, db, - "SELECT count(*) FROM datarights.deletion_jobs WHERE id = $1", auditID); got != 1 { - t.Errorf("datarights.deletion_jobs audit row was deleted: got %d, want 1", got) - } - - // Idempotency: a second run deletes zero rows and leaves the DB unchanged. - before := snapshot(t, db, adminID, userID, auditID) - runCleanupSQL(t, db) - after := snapshot(t, db, adminID, userID, auditID) - if !reflect.DeepEqual(before, after) { - t.Errorf("cleanup.sql is not idempotent: state changed on second run\nbefore: %v\nafter: %v", before, after) - } -} - -// runServiceMigrations applies the auth, finance, and datarights migrations to -// the test database, each on its own search_path and bookkeeping table. -func runServiceMigrations(t *testing.T, baseURL string) { - t.Helper() - for _, svc := range serviceMigrations { - dbURL := withParams(t, baseURL, map[string]string{ - "search_path": svc.searchPath, - "x-migrations-table": svc.migrationsTable, - }) - if err := dbmigrate.Run(dbURL, svc.dir); err != nil { - t.Fatalf("running %s migrations: %v", svc.searchPath, err) - } - } -} - -// openDB connects for seeding, running cleanup.sql, and asserting. Every query -// uses fully-qualified table names, so the connection carries no search_path. -func openDB(t *testing.T, baseURL string) *sql.DB { - t.Helper() - db, err := sql.Open("postgres", stripParams(t, baseURL, "search_path", "x-migrations-table")) - if err != nil { - t.Fatalf("opening test database: %v", err) - } - if err := db.Ping(); err != nil { - t.Fatalf("pinging test database: %v", err) - } - t.Cleanup(func() { _ = db.Close() }) - return db -} - -// seedUser inserts an auth.users row with the given role and returns its id. -// The row and everything it owns is removed on cleanup so the test re-runs. -func seedUser(t *testing.T, db *sql.DB, prefix, role string) string { - t.Helper() - suffix := time.Now().UnixNano() - var id string - err := db.QueryRow( - `INSERT INTO auth.users (username, email, password_hash, role) - VALUES ($1, $2, 'x', $3) RETURNING id::text`, - fmt.Sprintf("%s_%d", prefix, suffix), - fmt.Sprintf("%s_%d@cleanup.test", prefix, suffix), - role, - ).Scan(&id) - if err != nil { - t.Fatalf("seeding %s user: %v", role, err) - } - t.Cleanup(func() { deleteUserData(db, id) }) - return id -} - -// seedFinanceRows inserts one owner's set of finance rows plus an export job: -// a budget period, default settings, two tags, one pro-rata schedule, one -// export job. finance and datarights carry no cross-schema FK to auth.users -// (project convention), so synthetic tag/group ids are fine. -func seedFinanceRows(t *testing.T, db *sql.DB, userID string) { - t.Helper() - mustExec(t, db, `INSERT INTO finance.budget_periods - (user_id, year, month, budget_amount, essentials_percent, desires_percent, savings_percent) - VALUES ($1, 2026, 1, 100000, 50, 30, 20)`, userID) - mustExec(t, db, `INSERT INTO finance.default_settings (user_id) VALUES ($1)`, userID) - mustExec(t, db, `INSERT INTO finance.tags (user_id, name) VALUES ($1, 'Groceries'), ($1, 'Rent')`, userID) - mustExec(t, db, `INSERT INTO finance.pro_rata_schedules - (user_id, pro_rata_group, name, amount, currency, expense_type, tag_id, - target_year, target_month, installment_index, installment_total) - VALUES ($1, gen_random_uuid(), 'Annual insurance', 120000, 'USD', 'essentials', - gen_random_uuid(), 2026, 1, 1, 12)`, userID) - mustExec(t, db, `INSERT INTO datarights.export_jobs (user_id) VALUES ($1)`, userID) -} - -// seedDeletionJob inserts an audit row that records the admin acting on a user. -// cleanup.sql must never touch datarights.deletion_jobs. Returns its id. -func seedDeletionJob(t *testing.T, db *sql.DB, userID, adminID string) string { - t.Helper() - var id string - err := db.QueryRow( - `INSERT INTO datarights.deletion_jobs (user_id, admin_user_id) - VALUES ($1, $2) RETURNING id::text`, userID, adminID, - ).Scan(&id) - if err != nil { - t.Fatalf("seeding deletion job: %v", err) - } - return id -} - -// runCleanupSQL reads and executes the exact shipping cleanup.sql file. It has -// no parameters, so lib/pq runs the whole BEGIN; ... COMMIT; as one statement. -func runCleanupSQL(t *testing.T, db *sql.DB) { - t.Helper() - content, err := os.ReadFile(cleanupSQLPath) - if err != nil { - t.Fatalf("reading cleanup.sql at %s: %v", cleanupSQLPath, err) - } - if _, err := db.Exec(string(content)); err != nil { - t.Fatalf("executing cleanup.sql: %v", err) - } -} - -// snapshot captures the owner-scoped counts the idempotency check compares. -func snapshot(t *testing.T, db *sql.DB, adminID, userID, auditID string) map[string]int { - t.Helper() - s := make(map[string]int) - for _, table := range targetTables { - s[table+"|admin"] = countByUser(t, db, table, adminID) - s[table+"|user"] = countByUser(t, db, table, userID) - } - s["auth.users|admin"] = queryCount(t, db, - "SELECT count(*) FROM auth.users WHERE id = $1 AND role = 'admin'", adminID) - s["datarights.deletion_jobs|audit"] = queryCount(t, db, - "SELECT count(*) FROM datarights.deletion_jobs WHERE id = $1", auditID) - return s -} - -// deleteUserData removes everything a seeded user owns plus the user row, so a -// crashed prior run never leaves rows that break the next one. -func deleteUserData(db *sql.DB, id string) { - for _, stmt := range []string{ - "DELETE FROM finance.pro_rata_schedules WHERE user_id = $1", - "DELETE FROM finance.tags WHERE user_id = $1", - "DELETE FROM finance.budget_periods WHERE user_id = $1", - "DELETE FROM finance.default_settings WHERE user_id = $1", - "DELETE FROM datarights.export_jobs WHERE user_id = $1", - "DELETE FROM datarights.deletion_jobs WHERE user_id = $1 OR admin_user_id = $1", - "DELETE FROM auth.users WHERE id = $1", - } { - _, _ = db.Exec(stmt, id) - } -} - -// countByUser counts rows a user owns in the given fully-qualified table. The -// table name comes from targetTables (this package), never external input. -func countByUser(t *testing.T, db *sql.DB, table, userID string) int { - t.Helper() - return queryCount(t, db, "SELECT count(*) FROM "+table+" WHERE user_id = $1", userID) -} - -func queryCount(t *testing.T, db *sql.DB, query string, args ...any) int { - t.Helper() - var n int - if err := db.QueryRow(query, args...).Scan(&n); err != nil { - t.Fatalf("count query failed: %v\nquery: %s", err, query) - } - return n -} - -func mustExec(t *testing.T, db *sql.DB, query string, args ...any) { - t.Helper() - if _, err := db.Exec(query, args...); err != nil { - t.Fatalf("exec failed: %v\nquery: %s", err, query) - } -} - -// withParams returns baseURL with the given query params set (added or replaced). -func withParams(t *testing.T, baseURL string, params map[string]string) string { - t.Helper() - parsed, err := url.Parse(baseURL) - if err != nil { - t.Fatalf("parsing TEST_DATABASE_URL: %v", err) - } - q := parsed.Query() - for k, v := range params { - q.Set(k, v) - } - parsed.RawQuery = q.Encode() - return parsed.String() -} - -// stripParams returns baseURL with the given query params removed. -func stripParams(t *testing.T, baseURL string, keys ...string) string { - t.Helper() - parsed, err := url.Parse(baseURL) - if err != nil { - t.Fatalf("parsing TEST_DATABASE_URL: %v", err) - } - q := parsed.Query() - for _, k := range keys { - q.Del(k) - } - parsed.RawQuery = q.Encode() - return parsed.String() -}