Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .mise.toml
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,7 @@ description = "Apply Drizzle migrations to the configured Postgres database"
run = "pnpm --filter @rakkr/db db:migrate"

[tasks."db:verify"]
description = "Replay Drizzle migrations against a fresh throwaway Postgres database"
description = "Replay Drizzle migrations against an in-process PGlite database (no Docker)"
run = "pnpm --filter @rakkr/db db:verify"

[tasks.release]
Expand Down
17 changes: 16 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,8 @@ barrel; tables live in per-subsystem modules under `packages/db/src/schema/`.
- Edit the matching table module under `packages/db/src/schema/` first
(re-exported by `schema.ts`; `drizzle.config.ts` reads `schema.ts`).
- `mise run db:generate`, review the SQL/metadata under `packages/db/drizzle`,
then `mise run db:verify` (replays migrations against a throwaway Postgres).
then `mise run db:verify` (replays migrations against an in-process PGlite
database — no Docker/Postgres server needed).
- Commit generated migration files with the schema change.

## Gates And Checks
Expand Down Expand Up @@ -135,6 +136,7 @@ pnpm --filter @rakkr/api test # sets RAKKR_API_NO_LISTEN=1; drops DATABASE_
pnpm --filter @rakkr/web test
pnpm --filter @rakkr/shared check
pnpm --filter @rakkr/db check
mise run node:test-db # concurrency/race tests; needs real Postgres
mise run agent:fake-controller-smoke
```

Expand All @@ -143,6 +145,19 @@ setup/helpers in non-`.test.ts` modules so the runner ignores them. Recorder
quick checks: `cargo run -p rakkr-recorder-agent -- --print-inventory` (or
`--print-meter-frame`).

DB tests split by what they exercise. **Persistence/round-trip** tests run against
an in-process PGlite (WASM Postgres) via `createPgliteDatabase()` from `@rakkr/db`
(`packages/db/src/client.ts`), so they need no server and run in the default
`node:test` suite — call it at the top, set `DATABASE_URL` to the returned
`pglite://…` url (or pass the url straight to `createDatabase`/`LocalAuthService`),
and close the handle in an `after`/`finally`. **Concurrency/race** tests (row-lock
and atomic compare-and-set contention) need genuinely concurrent Postgres
connections, which single-connection PGlite cannot model — those keep the
`RAKKR_API_TEST_DATABASE_URL` skip guard and run via `mise run node:test-db`
against a throwaway Postgres (listed in `run-db-integration-tests.mjs`). Do **not**
move a race test onto PGlite: it would pass vacuously (PGlite serializes
transactions) and mask a removed lock.

Ansible lifecycle smoke: `docker compose up -d --build ansible-runner
recorder-test-rig` then `mise run ansible:runner-smoke` (deploys the disposable
artifact into `recorder-test-rig`, runs `smoke_check`). Physical X32: set
Expand Down
109 changes: 53 additions & 56 deletions apps/api/test/auth-access-tx.test.ts
Original file line number Diff line number Diff line change
@@ -1,74 +1,71 @@
import assert from "node:assert/strict";
import { randomUUID } from "node:crypto";
import test from "node:test";
import { createDatabase, eq, userRoles, users } from "@rakkr/db";
import test, { after } from "node:test";
import { createDatabase, createPgliteDatabase, eq, userRoles, users } from "@rakkr/db";

// Exercises the access-persistence path against a real Postgres. Runs only when a
// test DB is provided via RAKKR_API_TEST_DATABASE_URL (repo convention). Run with
// `--test-force-exit` — the db client pool has no exposed close.
// Exercises the access-persistence path against real Postgres SQL semantics via
// an in-process PGlite (WASM Postgres) database, so it needs no running server.
//
// Guards R26-ACCESS-TX: persistLocalUserAccess DELETEs a user's roles/grants/groups
// and then INSERTs the new set. If an INSERT fails (e.g. a role id violating the
// user_roles -> roles FK) after the DELETEs autocommit, the user is left with ALL
// access stripped in the DB while the caller throws. Wrapping the delete+insert
// block in a single transaction must roll the DELETEs back so a failed insert
// leaves the user's PRE-EXISTING access intact.
const dbUrl = process.env.RAKKR_API_TEST_DATABASE_URL;
const pglite = await createPgliteDatabase("auth-access-tx");

after(() => pglite.close());

const { LocalAuthService } = await import("../src/auth-service.js");

test(
"DB: a failed access INSERT rolls back the DELETEs so prior access survives (R26-ACCESS-TX)",
{ skip: dbUrl ? false : "requires RAKKR_API_TEST_DATABASE_URL (Postgres)" },
async () => {
const db = createDatabase(dbUrl!);
const auth = new LocalAuthService(dbUrl);
const email = `access-tx-${randomUUID()}@example.com`;
test("DB: a failed access INSERT rolls back the DELETEs so prior access survives (R26-ACCESS-TX)", async () => {
const db = createDatabase(pglite.url);
const auth = new LocalAuthService(pglite.url);
const email = `access-tx-${randomUUID()}@example.com`;

const [row] = await db
.insert(users)
.values({ email, name: "Access TX", passwordHash: "x", provider: "local" })
.returning({ id: users.id });
const userId = row!.id;
const [row] = await db
.insert(users)
.values({ email, name: "Access TX", passwordHash: "x", provider: "local" })
.returning({ id: users.id });
const userId = row!.id;

try {
// Seed a real, valid role so the user starts with concrete access.
await auth.updateLocalUserAccess(userId, {
groupIds: [],
resourceGrants: [],
roles: ["operator"],
});
try {
// Seed a real, valid role so the user starts with concrete access.
await auth.updateLocalUserAccess(userId, {
groupIds: [],
resourceGrants: [],
roles: ["operator"],
});

const seeded = await db.select().from(userRoles).where(eq(userRoles.userId, userId));
assert.deepEqual(
seeded.map((entry) => entry.roleId),
["operator"],
"user must start with the seeded operator role",
);
const seeded = await db.select().from(userRoles).where(eq(userRoles.userId, userId));
assert.deepEqual(
seeded.map((entry) => entry.roleId),
["operator"],
"user must start with the seeded operator role",
);

// Drive persistLocalUserAccess with a role id that violates the
// user_roles -> roles FK. The DELETEs run first; the failing INSERT must
// roll the whole thing back rather than leave the user stripped.
await assert.rejects(
(
auth as unknown as {
persistLocalUserAccess: (
id: string,
access: { groupIds?: string[]; resourceGrants: never[]; roles: string[] },
groups: never[],
) => Promise<void>;
}
).persistLocalUserAccess(userId, { resourceGrants: [], roles: ["not-a-real-role"] }, []),
);
// Drive persistLocalUserAccess with a role id that violates the
// user_roles -> roles FK. The DELETEs run first; the failing INSERT must
// roll the whole thing back rather than leave the user stripped.
await assert.rejects(
(
auth as unknown as {
persistLocalUserAccess: (
id: string,
access: { groupIds?: string[]; resourceGrants: never[]; roles: string[] },
groups: never[],
) => Promise<void>;
}
).persistLocalUserAccess(userId, { resourceGrants: [], roles: ["not-a-real-role"] }, []),
);

const after = await db.select().from(userRoles).where(eq(userRoles.userId, userId));
assert.deepEqual(
after.map((entry) => entry.roleId),
["operator"],
"the failed insert must not strip the user's pre-existing role",
);
} finally {
await db.delete(users).where(eq(users.id, userId));
}
},
);
const after = await db.select().from(userRoles).where(eq(userRoles.userId, userId));
assert.deepEqual(
after.map((entry) => entry.roleId),
["operator"],
"the failed insert must not strip the user's pre-existing role",
);
} finally {
await db.delete(users).where(eq(users.id, userId));
}
});
68 changes: 29 additions & 39 deletions apps/api/test/auth-login-constraint.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import assert from "node:assert/strict";
import test from "node:test";
import test, { after } from "node:test";
import { createPgliteDatabase } from "@rakkr/db";

// Exercises the login persistence path against a real Postgres. Runs only when a
// test DB is provided via RAKKR_API_TEST_DATABASE_URL. DATABASE_URL must be set
// BEFORE importing the auth service. Run with `--test-force-exit` — the db client
// pool has no exposed close.
// Exercises the login persistence path against real Postgres SQL semantics via an
// in-process PGlite (WASM Postgres) database, so it needs no running server.
// DATABASE_URL must be set BEFORE importing the auth service.
//
// Guards the R8-DBLATCH scoping fix: a data-integrity error (SQLSTATE class 22/23)
// on the fire-and-forget login-session persistence must NOT abort the login. The
Expand All @@ -14,43 +14,33 @@ import test from "node:test";
// markDatabaseUnavailable re-threw the constraint error, so a valid credential
// login surfaced as 401. It must degrade: the session lives in memory, the login
// still returns a token.
const dbUrl = process.env.RAKKR_API_TEST_DATABASE_URL;

if (dbUrl) {
process.env.DATABASE_URL = dbUrl;
}
const pglite = await createPgliteDatabase("auth-login-constraint");

process.env.DATABASE_URL = pglite.url;
process.env.RAKKR_LOCAL_ADMIN_EMAIL = "[email protected]";
process.env.RAKKR_LOCAL_ADMIN_PASSWORD = "rakkr-login-constraint-password";

after(() => pglite.close());

const { LocalAuthService } = await import("../src/auth-service.js");

test(
"a valid login is not aborted by an over-long session ip_address (constraint error is not re-thrown)",
{
skip: dbUrl ? false : "requires RAKKR_API_TEST_DATABASE_URL (Postgres)",
},
async () => {
const service = new LocalAuthService(dbUrl);

// A realistic long forwarded-for chain: well past the varchar(120) budget.
const longForwardedFor = Array.from({ length: 12 }, (_, index) => `203.0.113.${index}`).join(
", ",
);
assert.ok(
longForwardedFor.length > 120,
"the forwarded-for chain must exceed the column budget",
);

const result = await service.login("[email protected]", "rakkr-login-constraint-password", {
ipAddress: longForwardedFor,
});

assert.ok(result.token, "login returns a session token despite the failed session persist");
assert.equal(result.user.email, "[email protected]");

// The session must still authenticate (served from the in-memory fallback).
const authed = await service.authenticate(`Bearer ${result.token}`);
assert.equal(authed.user?.email, "[email protected]");
},
);
test("a valid login is not aborted by an over-long session ip_address (constraint error is not re-thrown)", async () => {
const service = new LocalAuthService(pglite.url);

// A realistic long forwarded-for chain: well past the varchar(120) budget.
const longForwardedFor = Array.from({ length: 12 }, (_, index) => `203.0.113.${index}`).join(
", ",
);
assert.ok(longForwardedFor.length > 120, "the forwarded-for chain must exceed the column budget");

const result = await service.login("[email protected]", "rakkr-login-constraint-password", {
ipAddress: longForwardedFor,
});

assert.ok(result.token, "login returns a session token despite the failed session persist");
assert.equal(result.user.email, "[email protected]");

// The session must still authenticate (served from the in-memory fallback).
const authed = await service.authenticate(`Bearer ${result.token}`);
assert.equal(authed.user?.email, "[email protected]");
});
Loading