Stands up packages/api so the swipe path stops trusting its caller, and puts the auth library behind an interface we own. - packages/api: tRPC v11 with a Session type WE define, not one re-exported from an auth library. Swapping providers means rewriting one SessionResolver, not touching a router. - Procedure layers: public / protected / client / pro / verifiedPro / admin. Admin routes 404 rather than 403 so they cannot be probed. - deck router replaces the untrusted server action. Ownership is checked on every operation and returns NOT_FOUND, never FORBIDDEN, so job ids cannot be enumerated. 23 tests, mostly authorization. - packages/storage: presigned direct-to-R2 uploads. The server picks the key, so a caller can only write under their own user id. 14 tests. Three defects found and fixed: - The lazy db Proxy failed drizzle's is(db, PgDatabase) because it did not trap getPrototypeOf. Auth adapters dispatch on exactly that check, so this would have failed at runtime inside third-party code. Fixed and pinned with a regression test. - The open-request cap was a read-then-write race: concurrent swipes could both read 4 and both insert. Now one transaction with the job row locked. The cap is checked before the tombstone is written, so a rejected swipe leaves no trace and the card stays on the deck. - superjson was configured in two of the three required places. Without the QueryClient dehydrate/hydrate pair, RSC-prefetched data arrives as a raw envelope with no type error to warn you. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
352 lines
12 KiB
TypeScript
352 lines
12 KiB
TypeScript
/**
|
|
* Integration tests for the deck router, run against the live seeded database.
|
|
*
|
|
* pnpm services:up && pnpm db:migrate && pnpm db:seed
|
|
* pnpm --filter @linkder/api test
|
|
*
|
|
* The point of these is authorization. The swipe path previously lived in a Next
|
|
* server action that trusted whatever jobId it was handed, so anyone could swipe
|
|
* on anyone else's job. Most of what follows exists to prove that is now closed.
|
|
*/
|
|
import { config } from 'dotenv';
|
|
import { sql } from 'drizzle-orm';
|
|
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
|
import { MAX_OPEN_REQUESTS_PER_JOB } from '@linkder/shared';
|
|
|
|
config({ path: '../../.env' });
|
|
|
|
const { closePool, db } = await import('@linkder/db');
|
|
const { appRouter } = await import('../src/root');
|
|
const { createInnerContext } = await import('../src/context');
|
|
const { createCallerFactory } = await import('../src/trpc');
|
|
|
|
const createCaller = createCallerFactory(appRouter);
|
|
|
|
type Session = import('../src/context').Session;
|
|
|
|
function callerFor(session: Session | null) {
|
|
return createCaller(createInnerContext({ db, session }));
|
|
}
|
|
|
|
const clientSession = (userId: string): Session => ({
|
|
userId,
|
|
role: 'client',
|
|
name: 'Test Client',
|
|
email: 'client@test',
|
|
phone: null,
|
|
verificationStatus: null,
|
|
});
|
|
|
|
let jobId: string;
|
|
let ownerId: string;
|
|
let strangerId: string;
|
|
let adminId: string;
|
|
let verifiedProId: string;
|
|
let unverifiedProId: string;
|
|
|
|
beforeAll(async () => {
|
|
const jobs = await db.execute<{ id: string; client_id: string }>(
|
|
sql`SELECT id, client_id FROM jobs ORDER BY created_at LIMIT 1`,
|
|
);
|
|
const job = jobs[0];
|
|
if (!job) throw new Error('No seeded job — run `pnpm db:seed`');
|
|
jobId = job.id;
|
|
ownerId = job.client_id;
|
|
|
|
const others = await db.execute<{ id: string }>(
|
|
sql`SELECT id FROM users WHERE role = 'client' AND id <> ${ownerId} LIMIT 1`,
|
|
);
|
|
strangerId = others[0]!.id;
|
|
|
|
const admins = await db.execute<{ id: string }>(
|
|
sql`SELECT id FROM users WHERE role = 'admin' LIMIT 1`,
|
|
);
|
|
adminId = admins[0]!.id;
|
|
|
|
const verified = await db.execute<{ id: string }>(sql`
|
|
SELECT p.user_id AS id FROM pro_profiles p
|
|
JOIN pro_categories pc ON pc.pro_id = p.user_id
|
|
JOIN jobs j ON j.category_id = pc.category_id AND j.id = ${jobId}
|
|
WHERE p.verification_status = 'verified' AND p.is_accepting_jobs = true
|
|
LIMIT 1
|
|
`);
|
|
verifiedProId = verified[0]!.id;
|
|
|
|
const unverified = await db.execute<{ id: string }>(
|
|
sql`SELECT user_id AS id FROM pro_profiles WHERE verification_status <> 'verified' LIMIT 1`,
|
|
);
|
|
unverifiedProId = unverified[0]!.id;
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
await db.execute(sql`DELETE FROM requests WHERE job_id = ${jobId}`);
|
|
await db.execute(sql`DELETE FROM swipes WHERE job_id = ${jobId}`);
|
|
await db.execute(sql`UPDATE jobs SET status = 'open' WHERE id = ${jobId}`);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await db.execute(sql`DELETE FROM requests WHERE job_id = ${jobId}`);
|
|
await db.execute(sql`DELETE FROM swipes WHERE job_id = ${jobId}`);
|
|
await db.execute(sql`UPDATE jobs SET status = 'open' WHERE id = ${jobId}`);
|
|
await closePool();
|
|
});
|
|
|
|
describe('authorization — the reason this router exists', () => {
|
|
it('refuses an anonymous caller', async () => {
|
|
await expect(callerFor(null).deck.list({ jobId })).rejects.toThrow(/signed in/i);
|
|
});
|
|
|
|
it('refuses to show a stranger a deck that is not theirs', async () => {
|
|
await expect(callerFor(clientSession(strangerId)).deck.list({ jobId })).rejects.toThrow(
|
|
/not found/i,
|
|
);
|
|
});
|
|
|
|
it('refuses a swipe on a job the caller does not own', async () => {
|
|
await expect(
|
|
callerFor(clientSession(strangerId)).deck.swipe({
|
|
jobId,
|
|
proId: verifiedProId,
|
|
direction: 'right',
|
|
}),
|
|
).rejects.toThrow(/not found/i);
|
|
});
|
|
|
|
it('hides the existence of the job rather than admitting 403', async () => {
|
|
// A stranger must not be able to distinguish "exists but forbidden" from "no such job".
|
|
const real = await callerFor(clientSession(strangerId))
|
|
.deck.list({ jobId })
|
|
.catch((e: Error) => e.message);
|
|
const fake = await callerFor(clientSession(strangerId))
|
|
.deck.list({ jobId: '00000000-0000-4000-8000-000000000000' })
|
|
.catch((e: Error) => e.message);
|
|
expect(real).toBe(fake);
|
|
});
|
|
|
|
it('lets the owner through', async () => {
|
|
const result = await callerFor(clientSession(ownerId)).deck.list({ jobId });
|
|
expect(result.cards.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('lets an admin through', async () => {
|
|
const admin: Session = { ...clientSession(adminId), role: 'admin' };
|
|
const result = await callerFor(admin).deck.list({ jobId });
|
|
expect(result.cards.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('rejects a pro trying to browse a deck', async () => {
|
|
const proSession: Session = {
|
|
...clientSession(verifiedProId),
|
|
role: 'pro',
|
|
verificationStatus: 'verified',
|
|
};
|
|
await expect(callerFor(proSession).deck.list({ jobId })).rejects.toThrow(/only clients/i);
|
|
});
|
|
});
|
|
|
|
describe('swipe', () => {
|
|
it('records a left swipe without creating a request', async () => {
|
|
const caller = callerFor(clientSession(ownerId));
|
|
const result = await caller.deck.swipe({
|
|
jobId,
|
|
proId: verifiedProId,
|
|
direction: 'left',
|
|
});
|
|
expect(result.requested).toBe(false);
|
|
|
|
const rows = await db.execute<{ n: number }>(
|
|
sql`SELECT count(*)::int AS n FROM requests WHERE job_id = ${jobId}`,
|
|
);
|
|
expect(rows[0]!.n).toBe(0);
|
|
});
|
|
|
|
it('creates a pending request on a right swipe', async () => {
|
|
const caller = callerFor(clientSession(ownerId));
|
|
const result = await caller.deck.swipe({
|
|
jobId,
|
|
proId: verifiedProId,
|
|
direction: 'right',
|
|
});
|
|
expect(result.requested).toBe(true);
|
|
// Narrow the discriminated union before reading the right-swipe fields.
|
|
if (!result.requested) throw new Error('expected a request to be created');
|
|
expect(result.requestId).toBeTruthy();
|
|
// The seeded job is urgency 'now', so a 12h TTL.
|
|
expect(result.expiresInHours).toBe(12);
|
|
});
|
|
|
|
it('removes the pro from the deck once swiped', async () => {
|
|
const caller = callerFor(clientSession(ownerId));
|
|
const before = await caller.deck.list({ jobId });
|
|
await caller.deck.swipe({ jobId, proId: verifiedProId, direction: 'left' });
|
|
const after = await caller.deck.list({ jobId });
|
|
|
|
expect(after.cards.map((c) => c.proId)).not.toContain(verifiedProId);
|
|
expect(after.remaining).toBe(before.remaining - 1);
|
|
});
|
|
|
|
it('is idempotent — a double-tap does not create two requests', async () => {
|
|
const caller = callerFor(clientSession(ownerId));
|
|
await caller.deck.swipe({ jobId, proId: verifiedProId, direction: 'right' });
|
|
await caller.deck.swipe({ jobId, proId: verifiedProId, direction: 'right' });
|
|
|
|
const rows = await db.execute<{ n: number }>(
|
|
sql`SELECT count(*)::int AS n FROM requests WHERE job_id = ${jobId} AND pro_id = ${verifiedProId}`,
|
|
);
|
|
expect(rows[0]!.n).toBe(1);
|
|
});
|
|
|
|
it('refuses to send a job to an unverified pro even if asked directly', async () => {
|
|
await expect(
|
|
callerFor(clientSession(ownerId)).deck.swipe({
|
|
jobId,
|
|
proId: unverifiedProId,
|
|
direction: 'right',
|
|
}),
|
|
).rejects.toThrow(/not available/i);
|
|
});
|
|
|
|
it('enforces the open-request cap', async () => {
|
|
const caller = callerFor(clientSession(ownerId));
|
|
const { cards } = await caller.deck.list({ jobId, limit: 50 });
|
|
|
|
let sent = 0;
|
|
let capped = false;
|
|
for (const card of cards) {
|
|
try {
|
|
await caller.deck.swipe({ jobId, proId: card.proId, direction: 'right' });
|
|
sent++;
|
|
} catch (error) {
|
|
expect((error as Error).message).toMatch(/already have/i);
|
|
capped = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
// The seed has 5 eligible plumbers and the cap is 5, so the cap only trips if
|
|
// there are more candidates than the cap. Assert whichever case applies.
|
|
if (capped) {
|
|
expect(sent).toBe(5);
|
|
} else {
|
|
expect(sent).toBeLessThanOrEqual(5);
|
|
}
|
|
});
|
|
|
|
it('holds the cap under concurrent swipes', async () => {
|
|
// Regression: counting pending requests and then inserting one is a
|
|
// read-then-write race. Fired in parallel, the unguarded version let more
|
|
// than MAX_OPEN_REQUESTS_PER_JOB through.
|
|
const caller = callerFor(clientSession(ownerId));
|
|
const { cards } = await caller.deck.list({ jobId, limit: 50 });
|
|
|
|
const outcomes = await Promise.allSettled(
|
|
cards.map((card) =>
|
|
caller.deck.swipe({ jobId, proId: card.proId, direction: 'right' }),
|
|
),
|
|
);
|
|
|
|
const created = outcomes.filter((o) => o.status === 'fulfilled').length;
|
|
const [row] = await db.execute<{ n: number }>(
|
|
sql`SELECT count(*)::int AS n FROM requests WHERE job_id = ${jobId} AND status = 'pending'`,
|
|
);
|
|
|
|
expect(row!.n).toBeLessThanOrEqual(MAX_OPEN_REQUESTS_PER_JOB);
|
|
expect(row!.n).toBe(created);
|
|
});
|
|
|
|
it('leaves no swipe tombstone when the cap rejects the request', async () => {
|
|
const caller = callerFor(clientSession(ownerId));
|
|
const { cards } = await caller.deck.list({ jobId, limit: 50 });
|
|
if (cards.length <= MAX_OPEN_REQUESTS_PER_JOB) return; // not enough supply to trip the cap
|
|
|
|
for (let i = 0; i < MAX_OPEN_REQUESTS_PER_JOB; i++) {
|
|
await caller.deck.swipe({ jobId, proId: cards[i]!.proId, direction: 'right' });
|
|
}
|
|
|
|
const overflow = cards[MAX_OPEN_REQUESTS_PER_JOB]!;
|
|
await expect(
|
|
caller.deck.swipe({ jobId, proId: overflow.proId, direction: 'right' }),
|
|
).rejects.toThrow(/already have/i);
|
|
|
|
// The rejected pro must still be on the deck — nothing was written for them.
|
|
const [tombstone] = await db.execute<{ n: number }>(
|
|
sql`SELECT count(*)::int AS n FROM swipes WHERE job_id = ${jobId} AND pro_id = ${overflow.proId}`,
|
|
);
|
|
expect(tombstone!.n).toBe(0);
|
|
|
|
const after = await caller.deck.list({ jobId, limit: 50 });
|
|
expect(after.cards.map((c) => c.proId)).toContain(overflow.proId);
|
|
});
|
|
|
|
it('refuses a swipe on a cancelled job', async () => {
|
|
await db.execute(sql`UPDATE jobs SET status = 'cancelled' WHERE id = ${jobId}`);
|
|
await expect(
|
|
callerFor(clientSession(ownerId)).deck.swipe({
|
|
jobId,
|
|
proId: verifiedProId,
|
|
direction: 'right',
|
|
}),
|
|
).rejects.toThrow(/no longer taking offers/i);
|
|
});
|
|
|
|
it('rejects a malformed proId before touching the database', async () => {
|
|
await expect(
|
|
callerFor(clientSession(ownerId)).deck.swipe({
|
|
jobId,
|
|
proId: 'not-a-uuid',
|
|
direction: 'right',
|
|
}),
|
|
).rejects.toThrow();
|
|
});
|
|
});
|
|
|
|
describe('undo', () => {
|
|
it('puts a passed pro back on the deck', async () => {
|
|
const caller = callerFor(clientSession(ownerId));
|
|
await caller.deck.swipe({ jobId, proId: verifiedProId, direction: 'left' });
|
|
expect((await caller.deck.list({ jobId })).cards.map((c) => c.proId)).not.toContain(
|
|
verifiedProId,
|
|
);
|
|
|
|
await caller.deck.undo({ jobId, proId: verifiedProId });
|
|
expect((await caller.deck.list({ jobId })).cards.map((c) => c.proId)).toContain(verifiedProId);
|
|
});
|
|
|
|
it('refuses to undo a swipe that already reached the pro', async () => {
|
|
const caller = callerFor(clientSession(ownerId));
|
|
await caller.deck.swipe({ jobId, proId: verifiedProId, direction: 'right' });
|
|
await expect(caller.deck.undo({ jobId, proId: verifiedProId })).rejects.toThrow(
|
|
/already been sent/i,
|
|
);
|
|
});
|
|
|
|
it('refuses an undo from a stranger', async () => {
|
|
await expect(
|
|
callerFor(clientSession(strangerId)).deck.undo({ jobId, proId: verifiedProId }),
|
|
).rejects.toThrow(/not found/i);
|
|
});
|
|
});
|
|
|
|
describe('job router', () => {
|
|
it("lists only the caller's own jobs", async () => {
|
|
const mine = await callerFor(clientSession(ownerId)).job.mine();
|
|
expect(mine.length).toBeGreaterThan(0);
|
|
for (const job of mine) expect(job.clientId).toBe(ownerId);
|
|
|
|
const theirs = await callerFor(clientSession(strangerId)).job.mine();
|
|
expect(theirs.map((j) => j.id)).not.toContain(jobId);
|
|
});
|
|
|
|
it('exposes categories publicly', async () => {
|
|
const categories = await callerFor(null).job.categories();
|
|
expect(categories.length).toBeGreaterThan(0);
|
|
expect(categories.map((c) => c.name)).toContain('Plumber');
|
|
});
|
|
|
|
it("refuses to fetch someone else's job by id", async () => {
|
|
await expect(callerFor(clientSession(strangerId)).job.byId({ id: jobId })).rejects.toThrow(
|
|
/not found/i,
|
|
);
|
|
});
|
|
});
|