meta: task ref cleanup

This commit is contained in:
2026-07-23 21:54:52 -04:00
parent 8e33af43a5
commit f2fc6a5d1a
63 changed files with 658 additions and 485 deletions

View File

@@ -1,6 +1,6 @@
/**
* Unit tests for the generalized account-deletion-request email helpers
* (task 11).
* (see `misc.ts`).
*
* These are the pure, env-free helpers consumed by the
* `misc.sendDeletionRequestEmail` tRPC mutation (re-exported from `misc.ts`).
@@ -25,11 +25,11 @@ import {
} from "~/server/api/routers/deletion-email";
describe("DELETION_PRODUCT_SCHEMA", () => {
it("accepts \"lineage\"", () => {
it('accepts "lineage"', () => {
expect(DELETION_PRODUCT_SCHEMA.safeParse("lineage").success).toBe(true);
});
it("accepts \"nessa\"", () => {
it('accepts "nessa"', () => {
expect(DELETION_PRODUCT_SCHEMA.safeParse("nessa").success).toBe(true);
});
@@ -50,9 +50,7 @@ describe("deletionCookieName", () => {
});
it("returns distinct names per product", () => {
expect(deletionCookieName("lineage")).not.toBe(
deletionCookieName("nessa")
);
expect(deletionCookieName("lineage")).not.toBe(deletionCookieName("nessa"));
});
});

View File

@@ -1,6 +1,6 @@
/**
* Pure helpers for the generalized account-deletion-request email flow
* (task 11).
* (see `misc.ts`).
*
* Extracted from `src/server/api/routers/misc.ts` so they can be unit-tested
* in `bun:test` WITHOUT importing `~/env/server` (which validates ~30 secrets
@@ -49,7 +49,7 @@ export interface DeletionEmailContent {
* The operator email identifies the request name + requester email; the user
* email identifies the account being deleted + the 24h cancellation window.
* The `product` discriminator switches branding between Lineage (the original
* flow) and Nessa (task 11 — Nessa stores user data in its own Turso DB).
* flow) and Nessa (Nessa stores user data in its own Turso DB).
*
* `email` is interpolated verbatim into the HTML bodies. It has already been
* validated as a well-formed email by the tRPC input schema, and Sendinblue

View File

@@ -50,7 +50,7 @@ mock.module("~/env/server", () => ({
TURSO_DB_API_TOKEN: "test-token",
NESSA_DB_URL: "libsql://nessa-test.turso.io",
NESSA_DB_TOKEN: "test-token",
// Clerk env vars (required after migration in task 02)
// Clerk env vars (required after migration)
NESSA_CLERK_SECRET: "sk_test_test-secret",
NESSA_CLERK_JWT_ISSUER: "https://nessa-test.clerk.accounts.dev"
},
@@ -61,9 +61,8 @@ mock.module("~/env/server", () => ({
// Import after env mock is registered. These are the real verification
// functions used by web and Lineage surfaces respectively.
const { verifyAuthToken, verifyLineageAuthToken } = await import(
"~/server/auth"
);
const { verifyAuthToken, verifyLineageAuthToken } =
await import("~/server/auth");
// Issuer/audience claims the Lineage router stamps onto its tokens.
const { LINEAGE_CONFIG } = await import("~/config");

View File

@@ -1,10 +1,10 @@
/**
* p8-001 / p8-008 regression tests — S3 procedure lockdown & input sanitization.
*
* These tests verify the security remediation from task 02 without standing up
* These tests verify the security remediation without standing up
* the full tRPC router (which requires S3 / env / database / vinxi-runtime
* mocking that is unreliable under `bun test`). They follow the proven pattern
* from task 03 (p8-002): direct unit tests of the authz/sanitization helpers
* (p8-002): direct unit tests of the authz/sanitization helpers
* plus a static source-code audit that the previously-`publicProcedure` S3
* endpoints are now `csrfProtectedProcedure` (i.e. no longer anonymous).
*
@@ -136,7 +136,9 @@ describe("p8-001 / p8-008 static source audit", () => {
for (const proc of S3_PROCEDURES) {
it(`${proc} is not declared as publicProcedure`, () => {
// Match the procedure declaration line and ensure it is not publicProcedure.
const re = new RegExp(`\\b${proc}\\s*:\\s*(publicProcedure|csrfProtectedProcedure|protectedProcedure|adminProcedure|nessaProcedure)`);
const re = new RegExp(
`\\b${proc}\\s*:\\s*(publicProcedure|csrfProtectedProcedure|protectedProcedure|adminProcedure|nessaProcedure)`
);
const m = SOURCE.match(re);
expect(m, `${proc} declaration not found`).not.toBeNull();
expect(m![1]).not.toBe("publicProcedure");
@@ -144,7 +146,9 @@ describe("p8-001 / p8-008 static source audit", () => {
}
it("getDownloadUrl (Sparkle updater) remains the only public S3 endpoint", () => {
const m = SOURCE.match(/\bgetDownloadUrl\s*:\s*(publicProcedure|csrfProtectedProcedure|protectedProcedure)/);
const m = SOURCE.match(
/\bgetDownloadUrl\s*:\s*(publicProcedure|csrfProtectedProcedure|protectedProcedure)/
);
expect(m, "getDownloadUrl declaration not found").not.toBeNull();
expect(m![1]).toBe("publicProcedure");
});
@@ -153,7 +157,9 @@ describe("p8-001 / p8-008 static source audit", () => {
// Both simpleDeleteImage and deleteImage must call the ownership guard.
const deleteBlocks = SOURCE.split(/(\bsimpleDeleteImage:|\bdeleteImage:)/);
// Count occurrences of the ownership call within the delete mutation bodies.
const occurrences = (SOURCE.match(/assertS3KeyOwnership\(input\.key/g) || []).length;
const occurrences = (
SOURCE.match(/assertS3KeyOwnership\(input\.key/g) || []
).length;
expect(occurrences).toBeGreaterThanOrEqual(2);
});
});

View File

@@ -69,7 +69,7 @@ export function assertS3KeyOwnership(key: string, userId: string | null): void {
}
// ============================================================
// Account-deletion request email (task 11 — product-aware)
// Account-deletion request email — product-aware
// ============================================================
//
// Pure helpers live in `./deletion-email.ts` (env-free) so they can be unit-
@@ -368,7 +368,7 @@ export const miscRouter = createTRPCRouter({
turnstileToken: z.string().min(1, "Please complete the security check"),
/**
* Per-site subject prefix injected into the outbound email subject
* (task 09). Defaults to `"freno.me"` so existing callers (pre-task-09
* Defaults to `"freno.me"` so existing callers
* main-site contact form) keep emitting the byte-identical legacy
* subject `"freno.me Contact Request"`.
*/
@@ -499,7 +499,7 @@ export const miscRouter = createTRPCRouter({
.input(
z.object({
email: z.string().email(),
/** Product discriminator (task 11) — defaults to "lineage" for backward compat. */
/** Product discriminator — defaults to "lineage" for backward compat. */
product: DELETION_PRODUCT_SCHEMA.default("lineage")
})
)

View File

@@ -65,9 +65,15 @@ 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)");
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() {
@@ -86,7 +92,17 @@ function seed() {
// 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"]
[
CHALLENGE_CH,
CLUB_C,
"Run 5k",
"distance",
5000,
"2025-01-01",
"2025-12-31",
USER_A,
"active"
]
);
}
@@ -126,7 +142,9 @@ describe("p8-003: resolveClubIdFromPost", () => {
});
it("throws NOT_FOUND for a missing post", async () => {
expect(await errCode(resolveClubIdFromPost(conn, "no-such-post"))).toBe("NOT_FOUND");
expect(await errCode(resolveClubIdFromPost(conn, "no-such-post"))).toBe(
"NOT_FOUND"
);
});
});
@@ -136,17 +154,23 @@ describe("p8-003: resolveClubIdFromChallenge", () => {
});
it("throws NOT_FOUND for a missing challenge", async () => {
expect(await errCode(resolveClubIdFromChallenge(conn, "no-such-challenge"))).toBe("NOT_FOUND");
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();
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");
expect(await errCode(requireClubMembership(conn, CLUB_C, USER_B))).toBe(
"FORBIDDEN"
);
});
});
@@ -160,23 +184,31 @@ 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");
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();
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");
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();
await expect(
requireClubMembership(conn, clubId, USER_A)
).resolves.toBeUndefined();
});
});
@@ -184,21 +216,27 @@ 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");
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();
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");
expect(await errCode(requireClubMembership(conn, clubId, USER_B))).toBe(
"FORBIDDEN"
);
});
});
@@ -224,7 +262,9 @@ function initUsersTable() {
email TEXT,
clerkUserId TEXT
)`);
db.run(`CREATE INDEX IF NOT EXISTS idx_users_clerkUserId ON users(clerkUserId)`);
db.run(
`CREATE INDEX IF NOT EXISTS idx_users_clerkUserId ON users(clerkUserId)`
);
}
async function resolveLocalUserId(clerkUserId: string): Promise<string | null> {
@@ -246,31 +286,34 @@ describe("clerkUserId lookup (migrate-to-clerk-auth-03)", () => {
});
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]
);
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.
// No users seeded — the webhook 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"]
);
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]
);
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);

View File

@@ -23,7 +23,7 @@ mock.module("~/env/server", () => ({
TURSO_LINEAGE_TOKEN: "test-token",
TURSO_DB_API_TOKEN: "test-token",
NODE_ENV: "test",
// Clerk env vars (required after migration in task 02)
// Clerk env vars (required after migration)
NESSA_CLERK_SECRET: "sk_test_test-secret",
NESSA_CLERK_JWT_ISSUER: "https://nessa-test.clerk.accounts.dev"
},
@@ -55,7 +55,11 @@ const PROVIDER_ID = "prov-1";
// create/update/deleteWorkoutSplit
describe("assertWorkoutOwned helper", () => {
let assertWorkoutOwned: (conn: Client, workoutId: string, userId: string) => Promise<void>;
let assertWorkoutOwned: (
conn: Client,
workoutId: string,
userId: string
) => Promise<void>;
beforeEach(async () => {
const mod = await import("./nessa");
@@ -64,16 +68,16 @@ describe("assertWorkoutOwned helper", () => {
it("rejects when workout belongs to another user", async () => {
const conn = makeMockConn([{ userId: USER_B }]);
await expect(
assertWorkoutOwned(conn, WORKOUT_ID, USER_A)
).rejects.toThrow(/owner/);
await expect(assertWorkoutOwned(conn, WORKOUT_ID, USER_A)).rejects.toThrow(
/owner/
);
});
it("rejects when workout does not exist", async () => {
const conn = makeMockConn([]);
await expect(
assertWorkoutOwned(conn, WORKOUT_ID, USER_A)
).rejects.toThrow(/not found/i);
await expect(assertWorkoutOwned(conn, WORKOUT_ID, USER_A)).rejects.toThrow(
/not found/i
);
});
it("succeeds when workout belongs to the caller", async () => {
@@ -88,7 +92,11 @@ describe("assertWorkoutOwned helper", () => {
// Used by: updateAuthProvider, deleteAuthProvider
describe("assertAuthProviderOwned helper", () => {
let assertAuthProviderOwned: (conn: Client, providerId: string, userId: string) => Promise<void>;
let assertAuthProviderOwned: (
conn: Client,
providerId: string,
userId: string
) => Promise<void>;
beforeEach(async () => {
const mod = await import("./nessa");
@@ -121,7 +129,11 @@ describe("assertAuthProviderOwned helper", () => {
// Used by: updateExerciseLibrary, deleteExerciseLibrary
describe("assertExerciseLibraryOwned helper", () => {
let assertExerciseLibraryOwned: (conn: Client, exerciseId: string, userId: string) => Promise<void>;
let assertExerciseLibraryOwned: (
conn: Client,
exerciseId: string,
userId: string
) => Promise<void>;
beforeEach(async () => {
const mod = await import("./nessa");
@@ -273,9 +285,7 @@ describe("static audit: every targeted mutation handler uses ctx", () => {
];
it("no mutation handler in the list uses async ({ input }) without ctx", async () => {
const source = await Bun.file(
import.meta.dir + "/nessa.ts"
).text();
const source = await Bun.file(import.meta.dir + "/nessa.ts").text();
for (const name of MUTATIONS) {
// Match: name: nessaProcedure ... .mutation(async ({ input }) — but NOT ({ input, ctx
@@ -284,14 +294,15 @@ describe("static audit: every targeted mutation handler uses ctx", () => {
"s"
);
const match = source.match(re);
expect(match, `${name} should not use async ({ input }) — must use ctx`).toBeNull();
expect(
match,
`${name} should not use async ({ input }) — must use ctx`
).toBeNull();
}
});
it("every mutation handler in the list references ctx", async () => {
const source = await Bun.file(
import.meta.dir + "/nessa.ts"
).text();
const source = await Bun.file(import.meta.dir + "/nessa.ts").text();
for (const name of MUTATIONS) {
// Find the block for this mutation and check it references ctx
@@ -301,19 +312,16 @@ describe("static audit: every targeted mutation handler uses ctx", () => {
);
const match = source.match(re);
expect(match, `${name} mutation block not found`).toBeTruthy();
expect(
match![0].includes("ctx"),
`${name} must reference ctx`
).toBe(true);
expect(match![0].includes("ctx"), `${name} must reference ctx`).toBe(
true
);
}
});
it("bulkUpsert filters exerciseLibrary by userId", async () => {
const source = await Bun.file(
import.meta.dir + "/nessa.ts"
).text();
const source = await Bun.file(import.meta.dir + "/nessa.ts").text();
const bulkSection = source.match(
/if \(input\.exerciseLibrary\?\.length\) \{[\s\S]*?\n \}/
/if \(input\.exerciseLibrary\?\.length\) \{[\s\S]*?\n {8}\}/
);
expect(bulkSection).toBeTruthy();
expect(bulkSection![0]).toContain("userId !== ctx.nessaUserId");