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
7 changes: 4 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ jobs:
env:
JWT_SECRET: ci-only-do-not-use-in-prod-abcdef1234567890
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/interviewlab_test
NEXT_PUBLIC_APP_URL: http://localhost:3000
# Auth/API rate limits are tuned tight for production; the live-server
# integration suite below makes far more requests per minute than a
# real user would, so we relax the limits for this job only.
Expand All @@ -55,8 +56,8 @@ jobs:
- name: ESLint
run: bun run lint

- name: Push database schema
run: bun run db:push
- name: Apply database migrations
run: bun run db:deploy

- name: Seed database
run: bun run db:seed
Expand Down Expand Up @@ -91,4 +92,4 @@ jobs:
- name: Secret scan (gitleaks)
uses: gitleaks/gitleaks-action@v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ bun run test:api # API tests only
bun run lint # ESLint
bun run db:push # Schema push (dev)
bun run db:migrate # Create migration
bun run db:deploy # Apply committed migrations
bun run db:reset # Reset + seed
```

Expand Down
10 changes: 8 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,17 @@ Record user-visible and operationally meaningful changes. Use semantic versionin
- Standard documentation profile created: BUILD_LOG, BUILD_SPEC, PRD, ROADMAP, TESTING, SECURITY, DEPLOYMENT, ENGINEERING_DIARY, ERROR_LOG, LOOP_ENGINEERING.
- CONTRIBUTING.md and CHANGELOG.md added.
- PR template added to `.github/pull_request_template.md`.
- Revocation-aware session versions and `POST /api/auth/logout-all`.
- Prisma migration baseline and incremental session-version migration.

### Changed

- No released behavior has changed yet.
- CI now applies committed Prisma migrations instead of inferring schema changes with `db push`.
- Session restoration resolves the current database user and rejects revoked token versions.

### Fixed

- No fixes recorded yet.
- Admin question, guide, and download mutations now reject missing, opaque, or cross-origin browser
requests before database writes.
- Persistent login and registration rate limits no longer trust spoofable forwarding headers.
- Middleware rate limits use only the verified hosting proxy identity contract.
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,13 @@ bun test -t "creates user with valid email" # run tests matching a name

bun run db:push # push prisma/schema.prisma to Postgres (dev)
bun run db:migrate # create + apply a migration
bun run db:deploy # apply committed migrations (CI/production)
bun run db:reset # reset db and reseed
bun run db:seed # tsx prisma/seed.ts
bun run db:generate # regenerate Prisma client
```

Bun is the package manager (`bun.lock` is committed — don't add a `package-lock.json`). CI (`.github/workflows/ci.yml`) also uses Bun; it runs `tsc --noEmit`, `lint`, `db:push`, `db:seed`, `bun run test`, `bun run build`, then boots the built server and runs a live-server integration subset (`__tests__/api/auth.test.ts`, `resources.test.ts`, `questions-interview-ai.test.ts`, `user-paths.test.ts`) against it before a gitleaks secret scan. Match that sequence when validating a change end-to-end.
Bun is the package manager (`bun.lock` is committed — don't add a `package-lock.json`). CI (`.github/workflows/ci.yml`) also uses Bun; it runs `tsc --noEmit`, `lint`, `db:deploy`, `db:seed`, `bun run test`, `bun run build`, then boots the built server and runs a live-server integration subset (`__tests__/api/auth.test.ts`, `resources.test.ts`, `questions-interview-ai.test.ts`, `user-paths.test.ts`) against it before a gitleaks secret scan. Match that sequence when validating a change end-to-end.

Database is **PostgreSQL only** (`prisma/schema.prisma` `provider = "postgresql"`) — despite older docs mentioning SQLite for MVP/dev, do not reintroduce SQLite.

Expand Down
76 changes: 76 additions & 0 deletions __tests__/api/admin-content-mutations.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
/** @vitest-environment node */
import { beforeEach, describe, expect, it, vi } from 'vitest';

const getUserFromRequest = vi.fn();
const guideCreate = vi.fn();
const guideUpdate = vi.fn();
const downloadCreate = vi.fn();

vi.mock('@/lib/auth-helpers', () => ({
getUserFromRequest: (...args: unknown[]) => getUserFromRequest(...args),
}));

vi.mock('@/lib/db', () => ({
db: {
guide: {
create: (...args: unknown[]) => guideCreate(...args),
update: (...args: unknown[]) => guideUpdate(...args),
},
download: {
create: (...args: unknown[]) => downloadCreate(...args),
},
},
}));

import { POST as createGuide } from '@/app/api/guides/route';
import { PUT as updateGuide } from '@/app/api/guides/[id]/route';
import { POST as createDownload } from '@/app/api/downloads/route';

function request(path: string, method: 'POST' | 'PUT', body: object, origin?: string): Request {
const headers = new Headers({ 'content-type': 'application/json' });
if (origin) headers.set('origin', origin);
return new Request(`https://interview-lab.example${path}`, {
method,
headers,
body: JSON.stringify(body),
});
}

describe('admin content mutation origin protection', () => {
beforeEach(() => {
vi.clearAllMocks();
process.env.NODE_ENV = 'test';
delete process.env.NEXT_PUBLIC_APP_URL;
getUserFromRequest.mockResolvedValue({ id: 'admin-1', isAdmin: true });
});

it.each([
['guide creation', () => createGuide(request('/api/guides', 'POST', {
title: 'Guide', slug: 'guide', content: 'Content',
})), guideCreate],
['guide update', () => updateGuide(
request('/api/guides/guide-1', 'PUT', { title: 'Updated' }),
{ params: Promise.resolve({ id: 'guide-1' }) },
), guideUpdate],
['download creation', () => createDownload(request('/api/downloads', 'POST', {
title: 'Template',
})), downloadCreate],
] as const)('rejects %s without an Origin before writing', async (_label, invoke, write) => {
const response = await invoke();

expect(response.status).toBe(403);
expect(await response.json()).toEqual({ error: 'Forbidden — untrusted request origin' });
expect(write).not.toHaveBeenCalled();
});

it('allows same-origin guide creation', async () => {
guideCreate.mockResolvedValue({ id: 'guide-1', title: 'Guide' });

const response = await createGuide(request('/api/guides', 'POST', {
title: 'Guide', slug: 'guide', content: 'Content',
}, 'https://interview-lab.example'));

expect(response.status).toBe(201);
expect(guideCreate).toHaveBeenCalledOnce();
});
});
73 changes: 73 additions & 0 deletions __tests__/api/admin-questions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/** @vitest-environment node */
import { beforeEach, describe, expect, it, vi } from 'vitest';

const getUserFromRequest = vi.fn();
const questionCreate = vi.fn();
const questionUpdate = vi.fn();

vi.mock('@/lib/auth-helpers', () => ({
getUserFromRequest: (...args: unknown[]) => getUserFromRequest(...args),
}));

vi.mock('@/lib/db', () => ({
db: {
question: {
create: (...args: unknown[]) => questionCreate(...args),
update: (...args: unknown[]) => questionUpdate(...args),
},
},
}));

import { POST, PUT } from '@/app/api/admin/questions/route';

function mutationRequest(method: 'POST' | 'PUT', body: object, origin?: string): Request {
const headers = new Headers({ 'content-type': 'application/json' });
if (origin) {
headers.set('origin', origin);
headers.set('sec-fetch-site', 'same-origin');
}

return new Request('https://interview-lab.example/api/admin/questions', {
method,
headers,
body: JSON.stringify(body),
});
}

describe('admin question mutation origin protection', () => {
beforeEach(() => {
vi.clearAllMocks();
process.env.NODE_ENV = 'test';
delete process.env.NEXT_PUBLIC_APP_URL;
getUserFromRequest.mockResolvedValue({ id: 'admin-1', isAdmin: true });
});

it.each([
['POST', POST, { question: 'How do you optimize bids?' }, questionCreate],
['PUT', PUT, { id: 'question-1', status: 'published' }, questionUpdate],
] as const)('rejects %s requests without an Origin before writing', async (
method,
handler,
body,
write,
) => {
const response = await handler(mutationRequest(method, body));

expect(response.status).toBe(403);
expect(await response.json()).toEqual({ error: 'Forbidden — untrusted request origin' });
expect(write).not.toHaveBeenCalled();
});

it('allows a same-origin admin to create a question', async () => {
questionCreate.mockResolvedValue({ id: 'question-1', question: 'How do you optimize bids?' });

const response = await POST(mutationRequest(
'POST',
{ question: 'How do you optimize bids?' },
'https://interview-lab.example',
));

expect(response.status).toBe(201);
expect(questionCreate).toHaveBeenCalledOnce();
});
});
27 changes: 17 additions & 10 deletions __tests__/api/auth-login.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,11 +64,13 @@ const baseUser = {
subscriptionTier: 'free',
isAdmin: false,
emailVerified: false,
sessionVersion: 4,
profile: null,
};

describe('POST /api/auth/login', () => {
beforeEach(() => {
process.env.TRUSTED_CLIENT_IP_HEADER = 'x-interview-lab-test-client-ip';
findUnique.mockReset();
update.mockReset();
verifyPassword.mockReset();
Expand Down Expand Up @@ -149,14 +151,20 @@ describe('POST /api/auth/login', () => {
expect(await res.json()).not.toHaveProperty('passwordHash');
});

it('creates a session cookie whose payload matches the user (sub/email/tier/isAdmin)', async () => {
it('creates a session cookie whose payload includes the current session version', async () => {
findUnique.mockResolvedValue({ ...baseUser, id: 'u3', email: 'admin@test.com', subscriptionTier: 'pro', isAdmin: true });
verifyPassword.mockResolvedValue(true);
const res = await login(req({ email: 'admin@test.com', password: 'correct-password' }));
const setCookie = res.headers.get('set-cookie')!;
const token = setCookie.match(/interviewlab_session=([^;]+)/)![1];
const payload = await verifyToken(token);
expect(payload).toMatchObject({ sub: 'u3', email: 'admin@test.com', tier: 'pro', isAdmin: true });
expect(payload).toMatchObject({
sub: 'u3',
email: 'admin@test.com',
tier: 'pro',
isAdmin: true,
sessionVersion: 4,
});
});

it('sanitizes email: trims whitespace and lowercases before the db lookup', async () => {
Expand Down Expand Up @@ -210,20 +218,19 @@ describe('POST /api/auth/login', () => {
expect(findUnique).not.toHaveBeenCalled();
});

it('keys the rate limiter by x-forwarded-for (first IP) when present', async () => {
it('keys the rate limiter by the trusted proxy header and ignores x-forwarded-for', async () => {
checkRateLimit.mockResolvedValue({ allowed: true, remaining: 9 });
findUnique.mockResolvedValue(null);
await login(req({ email: 'x@x.com', password: 'p' }, { 'x-forwarded-for': '1.2.3.4, 5.6.7.8' }));
expect(checkRateLimit).toHaveBeenCalledWith('1.2.3.4', 'auth-login', expect.any(Number), expect.any(Number));
await login(req({ email: 'x@x.com', password: 'p' }, {
'x-interview-lab-test-client-ip': '203.0.113.10',
'x-forwarded-for': '1.2.3.4, 5.6.7.8',
}));
expect(checkRateLimit).toHaveBeenCalledWith('203.0.113.10', 'auth-login', expect.any(Number), expect.any(Number));
});

it('falls back to x-real-ip, then "unknown", for rate-limit keying', async () => {
it('falls back to "unknown" rather than trusting x-real-ip', async () => {
findUnique.mockResolvedValue(null);
await login(req({ email: 'x@x.com', password: 'p' }, { 'x-real-ip': '10.0.0.1' }));
expect(checkRateLimit).toHaveBeenCalledWith('10.0.0.1', 'auth-login', expect.any(Number), expect.any(Number));

checkRateLimit.mockClear();
await login(req({ email: 'x@x.com', password: 'p' }));
expect(checkRateLimit).toHaveBeenCalledWith('unknown', 'auth-login', expect.any(Number), expect.any(Number));
});
});
Expand Down
57 changes: 57 additions & 0 deletions __tests__/api/auth-logout-all.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/** @vitest-environment node */
import { beforeEach, describe, expect, it, vi } from 'vitest';

const getUserFromRequest = vi.fn();
const revokeAllUserSessions = vi.fn();

vi.mock('@/lib/auth-helpers', () => ({
getUserFromRequest: (...args: unknown[]) => getUserFromRequest(...args),
}));

vi.mock('@/lib/session-revocation', () => ({
revokeAllUserSessions: (...args: unknown[]) => revokeAllUserSessions(...args),
}));

import { POST } from '@/app/api/auth/logout-all/route';

function request(): Request {
return new Request('http://localhost/api/auth/logout-all', { method: 'POST' });
}

describe('POST /api/auth/logout-all', () => {
beforeEach(() => {
vi.clearAllMocks();
revokeAllUserSessions.mockResolvedValue(undefined);
});

it('rejects an unauthenticated request without changing session state', async () => {
getUserFromRequest.mockResolvedValue(null);

const response = await POST(request());

expect(response.status).toBe(401);
expect(revokeAllUserSessions).not.toHaveBeenCalled();
});

it('revokes every user session and clears the current cookie', async () => {
getUserFromRequest.mockResolvedValue({ id: 'user-123456' });

const response = await POST(request());

expect(response.status).toBe(200);
expect(revokeAllUserSessions).toHaveBeenCalledWith('user-123456');
expect(response.headers.get('set-cookie')).toContain('interviewlab_session=');
expect(response.headers.get('set-cookie')).toMatch(/Max-Age=0/i);
});

it('returns a generic error without clearing the cookie when revocation fails', async () => {
getUserFromRequest.mockResolvedValue({ id: 'user-123456' });
revokeAllUserSessions.mockRejectedValue(new Error('database unavailable'));

const response = await POST(request());

expect(response.status).toBe(500);
expect(await response.json()).toEqual({ error: 'Failed to revoke sessions' });
expect(response.headers.get('set-cookie')).toBeNull();
});
});
Loading
Loading