Files
freno-dev/src/server/api/routers/nessa-community-authz.test.ts
Michael Freno 7287f10c9a feat: migrate Nessa auth to Clerk session tokens (task 03)
- src/server/nessa-auth.ts: replace jose HS256 sign/verify with Clerk
  session JWT verification via @clerk/backend verifyToken (RS256/JWKS).
  signNessaToken removed — frontend now supplies Clerk session tokens.
- src/server/api/utils.ts: createTRPCContext verifies Clerk JWT, resolves
  ctx.nessaUserId via SELECT id FROM users WHERE clerkUserId=? on the
  shared NessaConnectionFactory. Lookup miss throws typed UNAUTHORIZED
  (webhook has not run yet). Invalid/expired tokens are swallowed; the
  enforceNessaUser middleware rejects null nessaUserId.
- src/server/api/routers/nessa-community-authz.test.ts: add clerkUserId
  lookup tests (seeded match, missing row, mismatched id, local≠clerk).
- src/server/nessa-auth.test.ts: verifyNessaToken unit tests with mocked
  @clerk/backend (valid sub, missing sub, malformed/expired/wrong-signature
  rejection) plus static audit that signNessaToken is gone.
- src/server/clerk-user-webhook.ts + src/routes/api/clerk-webhook.ts:
  Clerk user.created/user.updated webhook handler (Svix signature
  verification, idempotent upsert by clerkUserId, lazy ALTER TABLE
  migration) with full test suite.
- src/server/api/routers/nessa.ts: remove legacy register/login/google/
  apple sign-in mutations (Clerk is now the sole identity provider).
- src/env/server.ts: add NESSA_CLERK_SECRET, NESSA_CLERK_JWT_ISSUER,
  NESSA_CLERK_WEBHOOK_SECRET; NESSA_JWT_SECRET moved to optional.
- package.json: add @clerk/backend, svix; lineage/auth.test.ts and
  nessa-ownership.test.ts: add Clerk env vars to env mocks.
- .env.example: document Clerk config vars and rotation.
- delete nessa-google-oauth.test.ts (Google auth removed).

ctx.nessaUserId remains the local users.id — router bodies are untouched.
2026-07-23 01:40:53 -04:00

279 lines
10 KiB
TypeScript

import { describe, it, expect, beforeAll, beforeEach } from "vitest";
import { Database } from "bun:sqlite";
import {
requireClubMembership,
resolveClubIdFromPost,
resolveClubIdFromChallenge,
type NessaConn
} from "./nessa-community-authz";
/**
* Regression tests for p8-003: private club content (posts, comments, likes,
* challenge participation) must NOT be readable/actionable by non-members.
*
* These tests exercise the shared membership-gating helpers directly against
* an in-memory SQLite DB (`bun:sqlite`) wrapped to match the libsql
* `execute({ sql, args }) -> { rows }` contract the router uses. The
* `nessa-community.ts` router calls these same helpers in the same order, so a
* pass here guarantees the authorization decision each endpoint makes before
* touching data.
*
* Two users are seeded: A is a member (owner) of club C (and owns the post +
* challenge under test); B is NOT a member of C.
*/
// ---------------------------------------------------------------------------
// In-memory SQLite connection (libsql-shaped)
// ---------------------------------------------------------------------------
let db: Database;
let conn: NessaConn;
function makeConn(): NessaConn {
return {
execute: async ({
sql,
args
}: {
sql: string;
args?: (string | number | null)[];
}) => {
const stmt = db.prepare(sql);
const upper = sql.trim().toUpperCase();
const isRead = upper.startsWith("SELECT") || upper.startsWith("WITH");
if (isRead) {
const rows = stmt.all(...(args ?? []));
return { rows: rows as unknown[] };
}
stmt.run(...(args ?? []));
return { rows: [] as unknown[] };
}
};
}
// ---------------------------------------------------------------------------
// Schema + seed
// ---------------------------------------------------------------------------
const USER_A = "user-a";
const USER_B = "user-b";
const CLUB_C = "club-c";
const POST_P = "post-p"; // created by A in club C
const CHALLENGE_CH = "challenge-ch"; // in club C, created by A
function initSchema() {
db = new Database(":memory:");
db.run("PRAGMA foreign_keys = ON");
db.run("CREATE TABLE clubMemberships (id TEXT PRIMARY KEY, clubId TEXT, userId TEXT, role TEXT, joinedAt TEXT)");
db.run("CREATE TABLE clubPosts (id TEXT PRIMARY KEY, clubId TEXT, userId TEXT, content TEXT, postType TEXT, challengeId TEXT, createdAt TEXT, updatedAt TEXT)");
db.run("CREATE TABLE clubChallenges (id TEXT PRIMARY KEY, clubId TEXT, title TEXT, description TEXT, goalType TEXT, goalValue REAL, startDate TEXT, endDate TEXT, createdBy TEXT, status TEXT, createdAt TEXT, updatedAt TEXT)");
}
function seed() {
// Club C: A is a member (owner). B is NOT.
db.run(
"INSERT INTO clubMemberships (id, clubId, userId, role, joinedAt) VALUES (?, ?, ?, ?, datetime('now'))",
["mem-a", CLUB_C, USER_A, "owner"]
);
// Post P by A in club C.
db.run(
"INSERT INTO clubPosts (id, clubId, userId, content, postType, challengeId, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?, NULL, datetime('now'), datetime('now'))",
[POST_P, CLUB_C, USER_A, "Hello from A", "text"]
);
// Challenge CH in club C, created by A.
db.run(
"INSERT INTO clubChallenges (id, clubId, title, description, goalType, goalValue, startDate, endDate, createdBy, status, createdAt, updatedAt) VALUES (?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'))",
[CHALLENGE_CH, CLUB_C, "Run 5k", "distance", 5000, "2025-01-01", "2025-12-31", USER_A, "active"]
);
}
// ---------------------------------------------------------------------------
beforeAll(() => {
initSchema();
seed();
conn = makeConn();
});
beforeEach(() => {
// Keep membership state stable across tests (join/leave integration mutates it).
db.run("DELETE FROM clubMemberships");
db.run(
"INSERT INTO clubMemberships (id, clubId, userId, role, joinedAt) VALUES (?, ?, ?, ?, datetime('now'))",
["mem-a", CLUB_C, USER_A, "owner"]
);
});
async function errCode(p: Promise<unknown>): Promise<string | undefined> {
try {
await p;
return undefined;
} catch (e) {
return (e as { code?: string }).code;
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("p8-003: resolveClubIdFromPost", () => {
it("resolves the owning club for an existing post", async () => {
expect(await resolveClubIdFromPost(conn, POST_P)).toBe(CLUB_C);
});
it("throws NOT_FOUND for a missing post", async () => {
expect(await errCode(resolveClubIdFromPost(conn, "no-such-post"))).toBe("NOT_FOUND");
});
});
describe("p8-003: resolveClubIdFromChallenge", () => {
it("resolves the owning club for an existing challenge", async () => {
expect(await resolveClubIdFromChallenge(conn, CHALLENGE_CH)).toBe(CLUB_C);
});
it("throws NOT_FOUND for a missing challenge", async () => {
expect(await errCode(resolveClubIdFromChallenge(conn, "no-such-challenge"))).toBe("NOT_FOUND");
});
});
describe("p8-003: requireClubMembership", () => {
it("passes silently for a member", async () => {
await expect(requireClubMembership(conn, CLUB_C, USER_A)).resolves.toBeUndefined();
});
it("throws FORBIDDEN for a non-member", async () => {
expect(await errCode(requireClubMembership(conn, CLUB_C, USER_B))).toBe("FORBIDDEN");
});
});
/**
* End-to-end authorization sequence for each of the 7 fixed endpoints. The
* router does exactly: resolve the resource's clubId, then
* requireClubMembership on it. Replaying that here proves the decision a
* non-member is rejected / a member is allowed.
*/
describe("p8-003: endpoint authorization sequences (resolve → require)", () => {
// social.getPost / addComment / comments / like / unlike
it("getPost/addComment/comments/like/unlike: non-member B rejected with FORBIDDEN", async () => {
const clubId = await resolveClubIdFromPost(conn, POST_P);
expect(await errCode(requireClubMembership(conn, clubId, USER_B))).toBe("FORBIDDEN");
});
it("getPost/addComment/comments/like/unlike: member A allowed", async () => {
const clubId = await resolveClubIdFromPost(conn, POST_P);
await expect(requireClubMembership(conn, clubId, USER_A)).resolves.toBeUndefined();
});
// challenges.leave / challenges.submitProgress
it("challenges.leave / submitProgress: non-member B rejected with FORBIDDEN", async () => {
const clubId = await resolveClubIdFromChallenge(conn, CHALLENGE_CH);
expect(await errCode(requireClubMembership(conn, clubId, USER_B))).toBe("FORBIDDEN");
});
it("challenges.leave / submitProgress: member A allowed", async () => {
const clubId = await resolveClubIdFromChallenge(conn, CHALLENGE_CH);
await expect(requireClubMembership(conn, clubId, USER_A)).resolves.toBeUndefined();
});
});
describe("p8-003: join then allowed / leave then blocked (integration)", () => {
it("B is blocked, allowed after joining C, blocked again after leaving", async () => {
// Initially blocked.
const clubId = await resolveClubIdFromPost(conn, POST_P);
expect(await errCode(requireClubMembership(conn, clubId, USER_B))).toBe("FORBIDDEN");
// B joins.
db.run(
"INSERT INTO clubMemberships (id, clubId, userId, role, joinedAt) VALUES (?, ?, ?, ?, datetime('now'))",
["mem-b", CLUB_C, USER_B, "member"]
);
await expect(requireClubMembership(conn, clubId, USER_B)).resolves.toBeUndefined();
// B leaves.
db.run("DELETE FROM clubMemberships WHERE clubId = ? AND userId = ?", [
CLUB_C,
USER_B
]);
expect(await errCode(requireClubMembership(conn, clubId, USER_B))).toBe("FORBIDDEN");
});
});
// ---------------------------------------------------------------------------
// Clerk session → local users.id resolution (migrate-to-clerk-auth-03)
//
// `createTRPCContext` verifies a Clerk session JWT (`verifyNessaToken`)
// and resolves `ctx.nessaUserId` by looking up `users.id` via the indexed
// `clerkUserId` column. These tests exercise that lookup path against an
// in-memory SQLite DB so the contract is guaranteed:
// - seeded row with matching clerkUserId → local id resolved
// - missing local row → UNAUTHORIZED
// - the resolved id is the LOCAL users.id, never the Clerk sub
// ---------------------------------------------------------------------------
const CLERK_USER_ID = "user_test_abc123";
const LOCAL_USER_A = "local-user-a";
const LOCAL_USER_B = "local-user-b";
function initUsersTable() {
db.run(`CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
email TEXT,
clerkUserId TEXT
)`);
db.run(`CREATE INDEX IF NOT EXISTS idx_users_clerkUserId ON users(clerkUserId)`);
}
async function resolveLocalUserId(clerkUserId: string): Promise<string | null> {
const result = await conn.execute({
sql: "SELECT id FROM users WHERE clerkUserId = ?",
args: [clerkUserId]
});
if (result.rows.length === 0) return null;
return (result.rows[0] as { id: string }).id;
}
describe("clerkUserId lookup (migrate-to-clerk-auth-03)", () => {
beforeAll(() => {
initUsersTable();
});
beforeEach(() => {
db.run("DELETE FROM users");
});
it("resolves local users.id for a seeded clerkUserId", async () => {
db.run(
"INSERT INTO users (id, email, clerkUserId) VALUES (?, ?, ?)",
[LOCAL_USER_A, "a@nessa.app", CLERK_USER_ID]
);
expect(await resolveLocalUserId(CLERK_USER_ID)).toBe(LOCAL_USER_A);
});
it("returns null when no local row matches the clerkUserId", async () => {
// No users seeded — the webhook (task 04) has not run yet.
expect(await resolveLocalUserId(CLERK_USER_ID)).toBeNull();
});
it("returns null for a Clerk id that exists but maps to a different local user", async () => {
db.run(
"INSERT INTO users (id, email, clerkUserId) VALUES (?, ?, ?)",
[LOCAL_USER_B, "b@nessa.app", "user_test_other"]
);
expect(await resolveLocalUserId(CLERK_USER_ID)).toBeNull();
});
it("ctx.nessaUserId is the LOCAL id, never the Clerk sub", async () => {
db.run(
"INSERT INTO users (id, email, clerkUserId) VALUES (?, ?, ?)",
[LOCAL_USER_A, "a@nessa.app", CLERK_USER_ID]
);
const resolved = await resolveLocalUserId(CLERK_USER_ID);
expect(resolved).toBe(LOCAL_USER_A);
expect(resolved).not.toBe(CLERK_USER_ID);
});
});