logicsrc/apps/pwa/test/credshare-rekey.test.mjs
Anthony Ettinger 4ac750e65b
feat(pwa): move app.logicsrc.com from Turso/libSQL to Postgres via @profullstack/libsql-pg (#218)
Production reads DATABASE_URL (postgres://) through @profullstack/libsql-pg,
which keeps the @libsql/client surface; no query changed. A missing,
non-Postgres or leftover libsql:// URL fails at boot in production. Dev and
tests keep libSQL (file:/:memory:) as a devDependency. Postgres migrations in
src/migrations-pg/ (converted with libsql-pg convert-schema, same file names
so the copied _migrations ledger matches); migrate.mjs picks the dialect.
Tests run against Postgres with PWA_TEST_DATABASE_URL.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-25 09:28:40 -07:00

234 lines
9.7 KiB
JavaScript

// Integration tests for POST /api/credshare/vaults/:id/rekey.
//
// The endpoint is the last line of defence on a genuinely unrecoverable
// operation: the vault DEK exists only inside the grants, so a rotation that
// commits half-way, drops a secret, or leaves the caller ungranted destroys the
// vault permanently. These tests pin the guards that stop that.
//
// Runs against an in-memory libSQL database, so DATABASE_URL must be set before
// anything imports db.mjs.
process.env.DATABASE_URL = process.env.PWA_TEST_DATABASE_URL || ":memory:";
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import express from "express";
const here = dirname(fileURLToPath(import.meta.url));
const { db, run, get, all, isPostgres } = await import("../src/db.mjs");
const { credshareRouter } = await import("../src/routes/credshare.mjs");
/** Apply the schema this router depends on. */
async function migrate() {
// Against a real Postgres (PWA_TEST_DATABASE_URL) start from an empty schema;
// run with --test-concurrency=1 then, the files share one database.
if (isPostgres) {
await db.execute("DROP SCHEMA public CASCADE");
await db.execute("CREATE SCHEMA public");
}
for (const file of ["001_auth.sql", "002_credshare.sql"]) {
const sql = readFileSync(join(here, "..", "src", isPostgres ? "migrations-pg" : "migrations", file), "utf8");
for (const statement of sql.split(/;\s*$/m).map((s) => s.trim()).filter(Boolean)) {
await db.execute(statement);
}
}
}
/**
* Mount the router with a fixed acting user, mimicking a browser session.
* Returns a fetch-like helper bound to an ephemeral port.
*/
async function serve(actingUserId) {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
req.user = { id: actingUserId };
next();
});
app.use(credshareRouter);
const server = app.listen(0);
await new Promise((resolve) => server.once("listening", resolve));
const base = `http://127.0.0.1:${server.address().port}`;
return {
async post(path, body) {
const res = await fetch(`${base}${path}`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body)
});
return { status: res.status, body: await res.json() };
},
async get(path) {
const res = await fetch(`${base}${path}`);
return { status: res.status, body: await res.json() };
},
close: () => new Promise((resolve) => server.close(resolve)),
};
}
const now = Date.now();
/** A team with one vault, two granted members, and two secrets. */
async function seed() {
await run(`INSERT INTO users (id, email, created_at) VALUES (?,?,?)`, ["u_me", "me@example.com", now]);
await run(`INSERT INTO users (id, email, created_at) VALUES (?,?,?)`, ["u_them", "them@example.com", now]);
await run(`INSERT INTO credshare_keys (user_id, public_key, updated_at) VALUES (?,?,?)`, ["u_me", "pk-me", now]);
await run(`INSERT INTO credshare_keys (user_id, public_key, updated_at) VALUES (?,?,?)`, ["u_them", "pk-them", now]);
await run(`INSERT INTO credshare_teams (id, slug, name, created_by, created_at) VALUES (?,?,?,?,?)`, ["t1", "acme", "Acme", "u_me", now]);
await run(`INSERT INTO credshare_members (id, team_id, user_id, email, role, status, created_at) VALUES (?,?,?,?,?,?,?)`,
["m1", "t1", "u_me", "me@example.com", "owner", "active", now]);
await run(`INSERT INTO credshare_members (id, team_id, user_id, email, role, status, created_at) VALUES (?,?,?,?,?,?,?)`,
["m2", "t1", "u_them", "them@example.com", "member", "active", now]);
await run(`INSERT INTO credshare_vaults (id, team_id, name, created_by, created_at) VALUES (?,?,?,?,?)`, ["v1", "t1", "app--prod", "u_me", now]);
for (const uid of ["u_me", "u_them"]) {
await run(`INSERT INTO credshare_vault_grants (vault_id, user_id, wrapped_dek, granted_by, created_at) VALUES (?,?,?,?,?)`,
["v1", uid, `old-wrapped-${uid}`, "u_me", now]);
}
await run(`INSERT INTO credshare_secrets (vault_id, name, nonce, ciphertext, fingerprint, version, updated_by, updated_at) VALUES (?,?,?,?,?,?,?,?)`,
["v1", "API_KEY", "n1", "c1", "fp-api", 1, "u_me", now]);
await run(`INSERT INTO credshare_secrets (vault_id, name, nonce, ciphertext, fingerprint, version, updated_by, updated_at) VALUES (?,?,?,?,?,?,?,?)`,
["v1", "DB_URL", "n2", "c2", "fp-db", 1, "u_me", now]);
}
/** A well-formed rotation: same names, same fingerprints, fresh ciphertext. */
function validBody(overrides = {}) {
return {
grants: [
{ email: "me@example.com", wrappedDek: "new-wrapped-me" },
{ email: "them@example.com", wrappedDek: "new-wrapped-them" }
],
secrets: [
{ name: "API_KEY", nonce: "n1b", ciphertext: "c1b", fingerprint: "fp-api" },
{ name: "DB_URL", nonce: "n2b", ciphertext: "c2b", fingerprint: "fp-db" }
],
revoke: [],
...overrides
};
}
await migrate();
await seed();
test("re-keys every secret and grant in one commit", async (t) => {
const app = await serve("u_me");
t.after(() => app.close());
const res = await app.post("/api/credshare/vaults/v1/rekey", validBody());
assert.equal(res.status, 200);
assert.equal(res.body.rekeyed, 2);
const secrets = await all(`SELECT name, nonce, ciphertext, fingerprint, version FROM credshare_secrets WHERE vault_id = 'v1' ORDER BY name`);
// New ciphertext, bumped version -- and the fingerprint is untouched, which
// is the machine-checkable statement that no VALUE changed.
assert.deepEqual(secrets.map((s) => s.ciphertext), ["c1b", "c2b"]);
assert.deepEqual(secrets.map((s) => s.version), [2, 2]);
assert.deepEqual(secrets.map((s) => s.fingerprint), ["fp-api", "fp-db"]);
const grants = await all(`SELECT user_id, wrapped_dek FROM credshare_vault_grants WHERE vault_id = 'v1' ORDER BY user_id`);
assert.deepEqual(grants.map((g) => g.wrapped_dek), ["new-wrapped-me", "new-wrapped-them"]);
const audit = await get(`SELECT action FROM credshare_audit WHERE vault_id = 'v1' AND action = 'vault:rekey'`);
assert.equal(audit.action, "vault:rekey");
});
test("refuses a rotation that would change a value", async (t) => {
const app = await serve("u_me");
t.after(() => app.close());
const res = await app.post("/api/credshare/vaults/v1/rekey", validBody({
secrets: [
{ name: "API_KEY", nonce: "x", ciphertext: "x", fingerprint: "fp-DIFFERENT" },
{ name: "DB_URL", nonce: "n2c", ciphertext: "c2c", fingerprint: "fp-db" }
]
}));
assert.equal(res.status, 409);
assert.match(res.body.error, /never changes values/);
// Nothing was written.
const row = await get(`SELECT ciphertext FROM credshare_secrets WHERE vault_id = 'v1' AND name = 'DB_URL'`);
assert.equal(row.ciphertext, "c2b");
});
test("refuses a rotation that drops a secret", async (t) => {
const app = await serve("u_me");
t.after(() => app.close());
const res = await app.post("/api/credshare/vaults/v1/rekey", validBody({
secrets: [{ name: "API_KEY", nonce: "z", ciphertext: "z", fingerprint: "fp-api" }]
}));
assert.equal(res.status, 409);
assert.match(res.body.error, /covers 1 secret\(s\) but the vault holds 2/);
});
test("refuses to let the caller lock themselves out", async (t) => {
const app = await serve("u_me");
t.after(() => app.close());
const res = await app.post("/api/credshare/vaults/v1/rekey", validBody({
grants: [{ email: "them@example.com", wrappedDek: "new-wrapped-them" }]
}));
assert.equal(res.status, 422);
assert.match(res.body.error, /must include your own grant/);
});
test("refuses a rotation that grants nobody", async (t) => {
const app = await serve("u_me");
t.after(() => app.close());
const res = await app.post("/api/credshare/vaults/v1/rekey", validBody({ grants: [] }));
assert.equal(res.status, 422);
assert.match(res.body.error, /at least one member/);
});
test("revoking drops the grant row so access is not merely stale", async (t) => {
const app = await serve("u_me");
t.after(() => app.close());
const res = await app.post("/api/credshare/vaults/v1/rekey", validBody({
grants: [{ email: "me@example.com", wrappedDek: "newer-me" }],
secrets: [
{ name: "API_KEY", nonce: "n1d", ciphertext: "c1d", fingerprint: "fp-api" },
{ name: "DB_URL", nonce: "n2d", ciphertext: "c2d", fingerprint: "fp-db" }
],
revoke: ["them@example.com"]
}));
assert.equal(res.status, 200);
assert.deepEqual(res.body.revoked, ["them@example.com"]);
const theirs = await get(`SELECT 1 AS hit FROM credshare_vault_grants WHERE vault_id = 'v1' AND user_id = 'u_them'`);
assert.equal(theirs, null, "revoked member should have no grant row left");
// And the revocation is on the audit trail by name.
const ev = await get(`SELECT key_name FROM credshare_audit WHERE vault_id = 'v1' AND action = 'vault:revoke'`);
assert.equal(ev.key_name, "them@example.com");
});
test("a member with no vault access cannot re-key it", async (t) => {
// u_them was just revoked above, so they are an active member without a grant.
const app = await serve("u_them");
t.after(() => app.close());
const res = await app.post("/api/credshare/vaults/v1/rekey", validBody());
assert.equal(res.status, 403);
assert.match(res.body.error, /Only a member with vault access/);
});
test("grants expose public keys and status so a client can re-seal in one pass", async (t) => {
const app = await serve("u_me");
t.after(() => app.close());
const res = await app.get("/api/credshare/vaults/v1/grants");
assert.equal(res.status, 200);
const me = res.body.grants.find((g) => g.email === "me@example.com");
assert.equal(me.publicKey, "pk-me");
assert.equal(me.status, "active");
assert.equal(me.hasAccess, true);
});