Skip to content

Commit 7ac0057

Browse files
committed
fix: address CodeRabbit review on #20
Seven findings, all verified against the code first. Two were real bugs: - The non-local refusal message called new URL(cfg.databaseUrl).host to name the host — but that branch is reached BY unparseable URLs, so it threw a TypeError and printed a stack trace instead of the guidance. Confirmed: `new URL("not a url")` throws. Now degrades to "unparseable DATABASE_URL". - The entry-point guard was process.argv[1]?.endsWith("seed-dev.ts"). Rename the file or emit it as .js and main() silently stops running: the command exits 0 having seeded nothing. Now compares resolved paths against fileURLToPath(import.meta.url). The truncation table list was duplicated between scripts/seed-dev.ts and tests/helpers/db.ts. Drift there is silent — a new table missing from one copy leaves stale rows after --reset and fails no test. Both now import src/db/tables.ts, and a new test asserts the list matches information_schema, so adding a table without registering it breaks a test instead of leaking rows. Test fix: the session-reissue assertion compared two Sets for inequality, which passes if only one of six cookies changed. Now asserts full disjointness. Docs: the cookie step said the "value" is the printed string, but the script prints a full name=value assignment and devtools has separate fields — pasting the whole line fails to authenticate. The documented id range said 91_000_0xx while alts use 91_000_1xx; corrected to the reserved 91_000_000-91_999_999 range. And "later file first" was a confusing way to describe env precedence — now states that Next applies its own .env.local-wins rule while the tsx scripts get the same result from flag order.
1 parent 77535bf commit 7ac0057

5 files changed

Lines changed: 96 additions & 23 deletions

File tree

docs/ops.md

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -395,7 +395,9 @@ To use one:
395395
1. Open devtools on the app → **Application****Cookies** → the origin you are
396396
actually browsing (`http://localhost:3000` by default).
397397
2. Add a cookie whose **name** is your `SESSION_COOKIE_NAME` (default
398-
`authgd_session`) and whose **value** is the printed string.
398+
`authgd_session`). The **value** is only the text *after* the `=` — the
399+
script prints a full `name=value` assignment, but devtools has separate
400+
fields, and pasting the whole line into the value box fails to authenticate.
399401
3. **Path `/`.** Reload.
400402

401403
Set it on the origin you browse, not on whatever `APP_BASE_URL` happens to say.
@@ -415,8 +417,9 @@ Two behaviors worth knowing:
415417
seed has no legitimate remote use. `ALLOW_REMOTE_SEED=1` overrides it
416418
deliberately.
417419

418-
Character ids come from a reserved `91_000_0xx` block, chosen to sit clear of
419-
the `90_000_0xx` ids the e2e suite generates, so the two can never collide.
420+
Character ids come from the reserved **`91_000_000``91_999_999`** range
421+
(mains at `91_000_00x`, alts at `91_000_1xx`), chosen to sit clear of the
422+
`90_000_00x` ids `e2e/helpers.ts` generates, so the two can never collide.
420423

421424
### Real OAuth locally, over a tunnel
422425

@@ -436,8 +439,11 @@ ngrok http 3000 --domain your-stable-domain.ngrok-free.app
436439

437440
#### 2. Override `APP_BASE_URL` in `.env.local`
438441

439-
`.env.local` wins over `.env` (both are loaded, later file first), and `.env*` is
440-
gitignored apart from `.env.example`. Keeping the override in a second file means
442+
`.env.local` takes precedence over `.env`, and `.env*` is gitignored apart from
443+
`.env.example`. Both loaders agree on that: Next.js applies its own
444+
`.env.local`-wins rule for `npm run dev`, and the `worker` / `db:migrate` /
445+
`db:seed` scripts pass `--env-file-if-exists=.env` before
446+
`--env-file-if-exists=.env.local`, where the later flag overrides the earlier. Keeping the override in a second file means
441447
your working `.env` stays untouched and switching back is deleting one file.
442448

443449
```bash

scripts/seed-dev.ts

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,13 @@
1717
* duplicated lines and would put the e2e suite at risk to make dev nicer.
1818
* The session hashing is NOT duplicated: createSession below is the real one.
1919
*/
20+
import { resolve } from "node:path";
21+
import { fileURLToPath } from "node:url";
2022
import { eq, sql } from "drizzle-orm";
2123
import { loadConfig, type Config } from "@/config";
2224
import { createDb, type Db } from "@/db";
2325
import { account, character } from "@/db/schema";
26+
import { TRUNCATE_ALL_SQL } from "@/db/tables";
2427
import { encryptToken } from "@/lib/crypto";
2528
import { createSession, revokeAccountSessions } from "@/services/session";
2629

@@ -80,13 +83,10 @@ const SEED: SeedSpec[] = [
8083
},
8184
];
8285

83-
/** Same 11 tables as tests/helpers/db.ts. Never touches the pgboss schema. */
86+
/** Table list is shared with tests/helpers/db.ts so the two cannot drift.
87+
* Never touches the pgboss schema. */
8488
async function truncateAll(db: Db): Promise<void> {
85-
await db.execute(sql`
86-
TRUNCATE account, "character", discord_link, session, bootstrap_admin_grant,
87-
outbox, oauth_transaction, contact_sync_state, sync_run,
88-
wanderer_acl_observation, audit_log RESTART IDENTITY CASCADE
89-
`);
89+
await db.execute(sql.raw(TRUNCATE_ALL_SQL));
9090
}
9191

9292
/**
@@ -110,6 +110,15 @@ export function isLocalDatabase(url: string): boolean {
110110
}
111111
}
112112

113+
/** The refusal path is reached BY unparseable URLs, so it must not itself throw. */
114+
function describeHost(url: string): string {
115+
try {
116+
return new URL(url).host;
117+
} catch {
118+
return "unparseable DATABASE_URL";
119+
}
120+
}
121+
113122
async function upsertAccount(db: Db, cfg: Config, spec: SeedSpec): Promise<string> {
114123
// account.main_character_id's composite FK is DEFERRED (checked at COMMIT),
115124
// so the account and its main character must land in ONE transaction.
@@ -182,7 +191,7 @@ async function main(): Promise<void> {
182191

183192
if (!isLocalDatabase(cfg.databaseUrl) && process.env.ALLOW_REMOTE_SEED !== "1") {
184193
console.error(
185-
`refusing to seed a non-local database (${new URL(cfg.databaseUrl).host}).\n` +
194+
`refusing to seed a non-local database (${describeHost(cfg.databaseUrl)}).\n` +
186195
`This script writes fixture accounts and, with --reset, TRUNCATEs every table.\n` +
187196
`Set ALLOW_REMOTE_SEED=1 if you genuinely mean it.`,
188197
);
@@ -224,7 +233,13 @@ async function main(): Promise<void> {
224233
}
225234

226235
// Only run when invoked as a script, so tests can import seedDev directly.
227-
if (process.argv[1]?.endsWith("seed-dev.ts")) {
236+
// Compared as RESOLVED paths rather than by filename: an endsWith("seed-dev.ts")
237+
// check silently stops running main() if the file is ever renamed or emitted as
238+
// .js, and the command would exit 0 having seeded nothing.
239+
const invokedDirectly =
240+
process.argv[1] !== undefined &&
241+
resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url));
242+
if (invokedDirectly) {
228243
main().catch((err: unknown) => {
229244
console.error("seed failed:", err instanceof Error ? err.message : err);
230245
process.exit(1);

src/db/tables.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
/**
2+
* Every table this app owns, in one place.
3+
*
4+
* Two callers TRUNCATE the whole set — the test helper between tests
5+
* (tests/helpers/db.ts) and the dev seed's --reset (scripts/seed-dev.ts).
6+
* They used to keep separate copies of this list, which drifts silently: a new
7+
* table missing from one copy leaves stale rows behind and fails no test.
8+
* tests/seed-dev.ts asserts this list matches the database, so adding a table
9+
* without adding it here breaks a test instead of leaking rows.
10+
*
11+
* `character` is quoted because it is a reserved word in SQL.
12+
*/
13+
export const MANAGED_TABLES = [
14+
"account",
15+
'"character"',
16+
"discord_link",
17+
"session",
18+
"bootstrap_admin_grant",
19+
"outbox",
20+
"oauth_transaction",
21+
"contact_sync_state",
22+
"sync_run",
23+
"wanderer_acl_observation",
24+
"audit_log",
25+
] as const;
26+
27+
/** Bare table names, unquoted — for comparing against information_schema. */
28+
export const MANAGED_TABLE_NAMES: string[] = MANAGED_TABLES.map((t) =>
29+
t.replace(/"/g, ""),
30+
);
31+
32+
/**
33+
* Static SQL built from a hardcoded constant — no user input reaches this, so
34+
* it is safe to pass through sql.raw().
35+
*/
36+
export const TRUNCATE_ALL_SQL = `TRUNCATE ${MANAGED_TABLES.join(", ")} RESTART IDENTITY CASCADE`;

tests/helpers/db.ts

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,14 @@
11
import { sql } from "drizzle-orm";
22
import { migrate } from "drizzle-orm/node-postgres/migrator";
33
import { createDb, type Db } from "@/db";
4+
import { TRUNCATE_ALL_SQL } from "@/db/tables";
45

56
export const TEST_URL =
67
process.env.TEST_DATABASE_URL ?? "postgres://authgd:authgd@localhost:5433/authgd_test";
78

8-
/** Shared 11-table TRUNCATE used between tests to reset carry-over state. */
9+
/** Shared TRUNCATE used between tests. Table list lives in src/db/tables.ts. */
910
export async function truncateAll(db: Db): Promise<void> {
10-
await db.execute(sql`
11-
TRUNCATE account, "character", discord_link, session, bootstrap_admin_grant,
12-
outbox, oauth_transaction, contact_sync_state, sync_run,
13-
wanderer_acl_observation, audit_log RESTART IDENTITY CASCADE
14-
`);
11+
await db.execute(sql.raw(TRUNCATE_ALL_SQL));
1512
}
1613

1714
export async function setupTestDb() {

tests/seed-dev.test.ts

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
import { eq } from "drizzle-orm";
1+
import { eq, sql } from "drizzle-orm";
22
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
33
import { account, character, session } from "@/db/schema";
4+
import { MANAGED_TABLE_NAMES } from "@/db/tables";
45
import { isLocalDatabase, seedDev } from "../scripts/seed-dev";
56
import { testConfig } from "./helpers/config";
67
import { setupTestDb, truncateAll } from "./helpers/db";
@@ -87,9 +88,10 @@ describe("seedDev", () => {
8788
const second = await seedDev(ctx.db, cfg);
8889
// Not accumulating: still one per account, and the ids are new.
8990
expect(await ctx.db.select().from(session)).toHaveLength(second.length);
90-
expect(new Set(second.map((s) => s.sessionId))).not.toEqual(
91-
new Set(first.map((s) => s.sessionId)),
92-
);
91+
// Fully disjoint, not merely "not identical": a set inequality would pass
92+
// even if only one of the six cookies had been reissued.
93+
const firstIds = new Set(first.map((s) => s.sessionId));
94+
expect(second.every((s) => !firstIds.has(s.sessionId))).toBe(true);
9395
});
9496
});
9597

@@ -129,3 +131,20 @@ describe("isLocalDatabase", () => {
129131
}
130132
});
131133
});
134+
135+
describe("MANAGED_TABLES", () => {
136+
// --reset and the test-suite truncation both drive off this list. Before it
137+
// was shared, a new table could be missing from a copy and leave stale rows
138+
// with no test failing. This makes that drift break a test instead.
139+
it("matches every table actually in the database", async () => {
140+
const rows = await ctx.db.execute<{ table_name: string }>(sql`
141+
select table_name from information_schema.tables
142+
where table_schema = 'public' and table_type = 'BASE TABLE'
143+
`);
144+
const actual = rows.rows
145+
.map((r) => r.table_name)
146+
.filter((t) => !t.startsWith("__drizzle"))
147+
.sort();
148+
expect([...MANAGED_TABLE_NAMES].sort()).toEqual(actual);
149+
});
150+
});

0 commit comments

Comments
 (0)