Files
OpenFrame/tests/api/register.test.ts
T
yusufipek 4b3c3934dd feat(billing): defer the cardless trial for invited collaborators
An account that signs up through an invitation works on the inviter's
billing, so handing it a trial at signup spent its only trial before it
owned anything. The trial is now held back for collaborators and claimed
only explicitly: a Start Free Trial button on the new-workspace and
billing screens calls the new POST /api/billing/trial endpoint, which
grants the once-per-account trial atomically. Nothing starts the clock
as a side effect, and pure collaborators no longer see a trial-ending
banner about work that is not theirs.
2026-09-01 15:07:54 +03:00

473 lines
16 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('refuses a disposable mailbox with 400 and stores nothing', async () => {
const response = await post({
name: 'Throwaway Person',
email: '[email protected]',
password: PASSWORD,
});
expect(response.status).toBe(400);
expect(await db.user.count()).toBe(0);
});
// The block exists to stop trial farming, which is a self-signup problem. An
// invited collaborator was vouched for by a paying customer, so refusing their
// address would break that customer's review instead.
it('accepts a disposable mailbox when an invitation vouches for it', async () => {
const scenario = await seedProject();
const invitation = await createInvitation({
invitedById: scenario.owner.id,
scope: 'PROJECT',
projectId: scenario.project.id,
email: '[email protected]',
role: 'COMMENTATOR',
});
signedOut();
const response = await callRoute(
register,
registerRequest({
name: 'Invited Guest',
email: '[email protected]',
password: PASSWORD,
invitationToken: invitation.token,
})
);
expect(response.status).toBe(201);
expect(await db.user.count()).toBe(2);
});
// SMTP is configured in .env.test, so registration alone proves nothing about
// the address and grants no trial. Verification is what starts the clock.
it('leaves the trial unstarted until the address has been verified', async () => {
const response = await post({
name: 'Unverified Person',
email: '[email protected]',
password: PASSWORD,
});
expect(response.status).toBe(201);
const created = await db.user.findUniqueOrThrow({
where: { email: '[email protected]' },
});
expect(created.trialEndsAt).toBeNull();
expect(created.billingTrialConsumedAt).toBeNull();
});
// Registering through an invitation is the one case where the trial is held
// back even on an instance with no SMTP: the account is verified and created,
// but it joined somebody else's workspace and does not need a trial to work
// there. Creating a workspace of its own is what starts the clock.
it('grants no trial to an invited collaborator even without a verification step', async () => {
vi.stubEnv('SMTP_HOST', '');
vi.stubEnv('SMTP_USER', '');
vi.stubEnv('SMTP_PASSWORD', '');
const scenario = await seedProject();
const invitation = await createInvitation({
invitedById: scenario.owner.id,
scope: 'PROJECT',
projectId: scenario.project.id,
email: '[email protected]',
role: 'COMMENTATOR',
});
signedOut();
const response = await callRoute(
register,
registerRequest({
name: 'Invited Guest',
email: '[email protected]',
password: PASSWORD,
invitationToken: invitation.token,
})
);
expect(response.status).toBe(201);
const created = await db.user.findUniqueOrThrow({ where: { email: '[email protected]' } });
expect(created.emailVerified).toBeInstanceOf(Date);
expect(created.trialEndsAt).toBeNull();
expect(created.billingTrialConsumedAt).toBeNull();
});
it('starts the trial for somebody signing themselves up without SMTP', async () => {
vi.stubEnv('SMTP_HOST', '');
vi.stubEnv('SMTP_USER', '');
vi.stubEnv('SMTP_PASSWORD', '');
await post({ name: 'Self Hosted', email: '[email protected]', password: PASSWORD });
const created = await db.user.findUniqueOrThrow({ where: { email: '[email protected]' } });
expect(created.trialEndsAt).toBeInstanceOf(Date);
expect(created.billingTrialConsumedAt).toBeInstanceOf(Date);
});
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');
});
});