mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 09:36:08 +00:00
The repo had no automated tests. Every change was verified by hand. Adds four layers, 2023 tests in total, runnable with one command: - 1191 unit tests over the pure logic in lib/, including the full computeProjectAccess permission matrix and the billing gate - 167 component and hook tests in jsdom, covering the hooks that hold real logic rather than presentational wrappers - 647 API integration tests against a real Postgres, with only auth() mocked, including a data-driven sweep asserting that none of the 60 route modules answers 2xx to an unauthenticated caller - 18 Playwright specs driving a real browser against a real build Infrastructure: vitest.config.ts with three projects, a disposable Postgres and MinIO in docker-compose.test.yml, factories and helpers under tests/, scripts/test.sh as the single entry point, a pre-push hook running bun run verify, and CI split into check, test and e2e jobs. The test database is built with prisma db push plus a replay of the hand-written SQL, because prisma migrate deploy cannot build this schema from empty: the migration history has no captured baseline. This mirrors what scripts/docker-db-bootstrap.ts already does in production, and tests/setup/db-global.ts carries a drift guard so a new migration fails the run until someone reviews it. Production code is unchanged apart from one pure-function extraction out of use-video-player.ts, which was too large to test in jsdom. Several tests pin behaviour that looks wrong, each marked KNOWN BUG in place. TESTING.md section 12 records where the plan turned out to be wrong, and AGENTS.md now states which layer a change needs a test in.
370 lines
12 KiB
TypeScript
370 lines
12 KiB
TypeScript
import { createHash } from 'node:crypto';
|
|
import bcrypt from 'bcryptjs';
|
|
import { describe, expect, it, vi } from 'vitest';
|
|
import { db } from '@/lib/db';
|
|
import { POST as register } from '@/app/api/auth/register/route';
|
|
import { apiRequest, callRoute, readData } from '../helpers/request';
|
|
import { mailTo, sentMail } from '../helpers/mail';
|
|
import { signedOut } from '../helpers/session';
|
|
import { createInvitation, createUser, seedProject } from '../factories';
|
|
|
|
const INVITE_CODE = 'test-invite';
|
|
const PASSWORD = 'correct horse battery';
|
|
|
|
function registerRequest(body: unknown) {
|
|
return apiRequest('/api/auth/register', { body });
|
|
}
|
|
|
|
async function post(body: Record<string, unknown>): Promise<Response> {
|
|
signedOut();
|
|
return callRoute(register, registerRequest({ inviteCode: INVITE_CODE, ...body }));
|
|
}
|
|
|
|
describe('POST /api/auth/register', () => {
|
|
it.each([
|
|
[{ email: '[email protected]', password: PASSWORD }, 'a missing name'],
|
|
[{ name: 'A', email: '[email protected]', password: PASSWORD }, 'a one-character name'],
|
|
[{ name: 'x'.repeat(101), email: '[email protected]', password: PASSWORD }, 'a 101-character name'],
|
|
[{ name: 42, email: '[email protected]', password: PASSWORD }, 'a non-string name'],
|
|
[{ name: 'Valid Name', password: PASSWORD }, 'a missing email'],
|
|
[{ name: 'Valid Name', email: '[email protected]', password: 'short' }, 'a 5-character password'],
|
|
[
|
|
{ name: 'Valid Name', email: '[email protected]', password: 'x'.repeat(129) },
|
|
'a 129-character password',
|
|
],
|
|
[{ name: 'Valid Name', email: '[email protected]' }, 'a missing password'],
|
|
])('rejects %j with 400 (%s)', async (body, label) => {
|
|
const response = await post(body);
|
|
|
|
expect(response.status, label).toBe(400);
|
|
expect(await db.user.count()).toBe(0);
|
|
});
|
|
|
|
it.each([['no-at-sign'], ['nope@nodot'], ['double@@example.com'], ['sp [email protected]']])(
|
|
'returns 422 for the malformed address %s',
|
|
async (email) => {
|
|
const response = await post({ name: 'Valid Name', email, password: PASSWORD });
|
|
|
|
expect(response.status).toBe(422);
|
|
expect(await db.user.count()).toBe(0);
|
|
}
|
|
);
|
|
|
|
it('returns 403 when the invite code is missing', async () => {
|
|
signedOut();
|
|
|
|
const response = await callRoute(
|
|
register,
|
|
registerRequest({ name: 'Valid Name', email: '[email protected]', password: PASSWORD })
|
|
);
|
|
|
|
expect(response.status).toBe(403);
|
|
expect(await db.user.count()).toBe(0);
|
|
});
|
|
|
|
it.each([['wrong-code'], [''], ['test-invit'], ['test-invitee']])(
|
|
'returns 403 for the invite code %s',
|
|
async (inviteCode) => {
|
|
signedOut();
|
|
|
|
const response = await callRoute(
|
|
register,
|
|
registerRequest({
|
|
name: 'Valid Name',
|
|
email: '[email protected]',
|
|
password: PASSWORD,
|
|
inviteCode,
|
|
})
|
|
);
|
|
|
|
expect(response.status).toBe(403);
|
|
expect(await db.user.count()).toBe(0);
|
|
}
|
|
);
|
|
|
|
it('does not require an invite code when the flag is off', async () => {
|
|
vi.stubEnv('OPENFRAME_REQUIRE_INVITE_CODE', 'false');
|
|
signedOut();
|
|
|
|
const response = await callRoute(
|
|
register,
|
|
registerRequest({ name: 'Valid Name', email: '[email protected]', password: PASSWORD })
|
|
);
|
|
|
|
expect(response.status).toBe(201);
|
|
expect(await db.user.count()).toBe(1);
|
|
});
|
|
|
|
it('creates the account with a lowercased email and a bcrypt hash', async () => {
|
|
const response = await post({
|
|
name: ' Ada Lovelace ',
|
|
email: ' [email protected] ',
|
|
password: PASSWORD,
|
|
});
|
|
const payload = await readData<{
|
|
message: string;
|
|
user: { id: string; email: string; name: string };
|
|
emailVerificationRequired: boolean;
|
|
}>(response);
|
|
|
|
expect(response.status).toBe(201);
|
|
expect(payload.emailVerificationRequired).toBe(true);
|
|
expect(payload.user.email).toBe('[email protected]');
|
|
expect(payload.user.name).toBe('Ada Lovelace');
|
|
// The response envelope must not carry the hash, let alone the password.
|
|
expect(JSON.stringify(payload)).not.toContain(PASSWORD);
|
|
expect(payload.user).not.toHaveProperty('password');
|
|
|
|
const stored = await db.user.findUniqueOrThrow({
|
|
where: { email: '[email protected]' },
|
|
});
|
|
expect(stored.name).toBe('Ada Lovelace');
|
|
expect(stored.password).not.toBe(PASSWORD);
|
|
expect(stored.password).toMatch(/^\$2[aby]\$/);
|
|
expect(await bcrypt.compare(PASSWORD, stored.password!)).toBe(true);
|
|
// SMTP is configured in .env.test, so verification is enforced.
|
|
expect(stored.emailVerified).toBeNull();
|
|
});
|
|
|
|
it('stores only the digest of the verification token and mails the raw one', async () => {
|
|
const response = await post({
|
|
name: 'Ada Lovelace',
|
|
email: '[email protected]',
|
|
password: PASSWORD,
|
|
});
|
|
|
|
expect(response.status).toBe(201);
|
|
|
|
const record = await db.verificationToken.findFirstOrThrow();
|
|
expect(record.identifier).toBe('[email protected]');
|
|
expect(record.token).toMatch(/^[0-9a-f]{64}$/);
|
|
expect(record.expires.getTime()).toBeGreaterThan(Date.now());
|
|
|
|
const mails = mailTo('[email protected]');
|
|
expect(mails).toHaveLength(1);
|
|
|
|
const rawToken = mails[0].html?.match(/token=([0-9a-f]{64})/)?.[1];
|
|
expect(rawToken).toBeTruthy();
|
|
// The stored value must be the digest, not the token itself, or a database
|
|
// leak hands out live verification links.
|
|
expect(record.token).not.toBe(rawToken);
|
|
expect(createHash('sha256').update(rawToken!).digest('hex')).toBe(record.token);
|
|
});
|
|
|
|
it('returns 409 for a duplicate email regardless of case, and does not touch the existing row', async () => {
|
|
const existing = await createUser({ email: '[email protected]', password: 'a-different-one' });
|
|
|
|
const response = await post({
|
|
name: 'Impostor',
|
|
email: '[email protected]',
|
|
password: PASSWORD,
|
|
});
|
|
|
|
expect(response.status).toBe(409);
|
|
expect(await db.user.count()).toBe(1);
|
|
const stored = await db.user.findUniqueOrThrow({ where: { id: existing.id } });
|
|
expect(stored.password).toBe(existing.password);
|
|
expect(stored.name).toBe(existing.name);
|
|
expect(sentMail()).toEqual([]);
|
|
});
|
|
|
|
it('accepts a matching invitation token instead of the invite code, and applies the membership', async () => {
|
|
const scenario = await seedProject();
|
|
const invitation = await createInvitation({
|
|
invitedById: scenario.owner.id,
|
|
scope: 'PROJECT',
|
|
projectId: scenario.project.id,
|
|
email: '[email protected]',
|
|
role: 'ADMIN',
|
|
});
|
|
signedOut();
|
|
|
|
const response = await callRoute(
|
|
register,
|
|
registerRequest({
|
|
name: 'Invited Person',
|
|
email: '[email protected]',
|
|
password: PASSWORD,
|
|
invitationToken: invitation.token,
|
|
})
|
|
);
|
|
|
|
expect(response.status).toBe(201);
|
|
const created = await db.user.findUniqueOrThrow({ where: { email: '[email protected]' } });
|
|
const membership = await db.projectMember.findUniqueOrThrow({
|
|
where: { projectId_userId: { projectId: scenario.project.id, userId: created.id } },
|
|
});
|
|
expect(membership.role).toBe('ADMIN');
|
|
expect((await db.invitation.findUniqueOrThrow({ where: { id: invitation.id } })).status).toBe(
|
|
'ACCEPTED'
|
|
);
|
|
});
|
|
|
|
it('applies a workspace invitation membership', async () => {
|
|
const scenario = await seedProject();
|
|
const invitation = await createInvitation({
|
|
invitedById: scenario.owner.id,
|
|
scope: 'WORKSPACE',
|
|
workspaceId: scenario.workspace.id,
|
|
email: '[email protected]',
|
|
role: 'ADMIN',
|
|
});
|
|
signedOut();
|
|
|
|
const response = await callRoute(
|
|
register,
|
|
registerRequest({
|
|
name: 'Workspace Invitee',
|
|
email: '[email protected]',
|
|
password: PASSWORD,
|
|
invitationToken: invitation.token,
|
|
})
|
|
);
|
|
|
|
expect(response.status).toBe(201);
|
|
const created = await db.user.findUniqueOrThrow({ where: { email: '[email protected]' } });
|
|
expect(
|
|
(
|
|
await db.workspaceMember.findUniqueOrThrow({
|
|
where: { workspaceId_userId: { workspaceId: scenario.workspace.id, userId: created.id } },
|
|
})
|
|
).role
|
|
).toBe('ADMIN');
|
|
});
|
|
|
|
// The invitation is bound to an address. Registering with a different one must
|
|
// not inherit the membership.
|
|
it('returns 403 when the invitation token was issued to a different email', async () => {
|
|
const scenario = await seedProject();
|
|
const invitation = await createInvitation({
|
|
invitedById: scenario.owner.id,
|
|
scope: 'PROJECT',
|
|
projectId: scenario.project.id,
|
|
email: '[email protected]',
|
|
});
|
|
signedOut();
|
|
|
|
const response = await callRoute(
|
|
register,
|
|
registerRequest({
|
|
name: 'Wrong Person',
|
|
email: '[email protected]',
|
|
password: PASSWORD,
|
|
invitationToken: invitation.token,
|
|
})
|
|
);
|
|
|
|
expect(response.status).toBe(403);
|
|
expect(await db.user.count()).toBe(1);
|
|
expect(await db.projectMember.count()).toBe(0);
|
|
expect((await db.invitation.findUniqueOrThrow({ where: { id: invitation.id } })).status).toBe(
|
|
'PENDING'
|
|
);
|
|
});
|
|
|
|
it('returns 403 for an expired invitation token', async () => {
|
|
const scenario = await seedProject();
|
|
const invitation = await createInvitation({
|
|
invitedById: scenario.owner.id,
|
|
scope: 'PROJECT',
|
|
projectId: scenario.project.id,
|
|
email: '[email protected]',
|
|
expiresAt: new Date(Date.now() - 60_000),
|
|
});
|
|
signedOut();
|
|
|
|
const response = await callRoute(
|
|
register,
|
|
registerRequest({
|
|
name: 'Late Person',
|
|
email: '[email protected]',
|
|
password: PASSWORD,
|
|
invitationToken: invitation.token,
|
|
})
|
|
);
|
|
|
|
expect(response.status).toBe(403);
|
|
expect(await db.user.count()).toBe(1);
|
|
expect(await db.projectMember.count()).toBe(0);
|
|
});
|
|
|
|
it('returns 403 for an unknown invitation token', async () => {
|
|
signedOut();
|
|
|
|
const response = await callRoute(
|
|
register,
|
|
registerRequest({
|
|
name: 'Nobody',
|
|
email: '[email protected]',
|
|
password: PASSWORD,
|
|
invitationToken: 'not-a-real-token',
|
|
})
|
|
);
|
|
|
|
expect(response.status).toBe(403);
|
|
expect(await db.user.count()).toBe(0);
|
|
});
|
|
|
|
it('returns 403 for an already accepted invitation token', async () => {
|
|
const scenario = await seedProject();
|
|
const invitation = await createInvitation({
|
|
invitedById: scenario.owner.id,
|
|
scope: 'PROJECT',
|
|
projectId: scenario.project.id,
|
|
email: '[email protected]',
|
|
status: 'ACCEPTED',
|
|
acceptedAt: new Date(),
|
|
});
|
|
signedOut();
|
|
|
|
const response = await callRoute(
|
|
register,
|
|
registerRequest({
|
|
name: 'Reuser',
|
|
email: '[email protected]',
|
|
password: PASSWORD,
|
|
invitationToken: invitation.token,
|
|
})
|
|
);
|
|
|
|
expect(response.status).toBe(403);
|
|
expect(await db.user.count()).toBe(1);
|
|
});
|
|
|
|
it('auto-verifies the email when SMTP is not configured', async () => {
|
|
vi.stubEnv('SMTP_HOST', '');
|
|
vi.stubEnv('SMTP_USER', '');
|
|
vi.stubEnv('SMTP_PASSWORD', '');
|
|
|
|
const response = await post({
|
|
name: 'Self Hosted',
|
|
email: '[email protected]',
|
|
password: PASSWORD,
|
|
});
|
|
const payload = await readData<{ emailVerificationRequired: boolean }>(response);
|
|
|
|
expect(response.status).toBe(201);
|
|
expect(payload.emailVerificationRequired).toBe(false);
|
|
const stored = await db.user.findUniqueOrThrow({ where: { email: '[email protected]' } });
|
|
expect(stored.emailVerified).toBeInstanceOf(Date);
|
|
expect(await db.verificationToken.count()).toBe(0);
|
|
expect(sentMail()).toEqual([]);
|
|
});
|
|
|
|
it('reports the rate limit budget on a successful registration', async () => {
|
|
const response = await post({
|
|
name: 'Rate Limited',
|
|
email: '[email protected]',
|
|
password: PASSWORD,
|
|
});
|
|
|
|
expect(response.status).toBe(201);
|
|
expect(response.headers.get('X-RateLimit-Limit')).toBe('5');
|
|
// The exact value, not just "present": .env.test sets DISABLE_RATE_LIMIT, so
|
|
// checkRateLimit() short-circuits to a full budget. toBeTruthy() held for any
|
|
// non-empty string, including a wrong one, which left the arithmetic behind
|
|
// this header untested from here.
|
|
expect(response.headers.get('X-RateLimit-Remaining')).toBe('5');
|
|
});
|
|
});
|