Files
OpenFrame/tests/api/register.test.ts
T
yusufipk 39e81042bb feat(billing): let people try the product before handing over a card
The trial now starts inside the product, at email verification, and Stripe
grants none at all: checkout creates a subscription that bills immediately.
Verifying an address is what buys the seven days, which is also the cheapest
abuse control there is.

An unexpired trial is treated as an entitlement the account already holds, so a
Stripe sync can add access but never retracts a trial that has not run out. That
matters most for the abandoned checkout: the resulting incomplete subscription
carries no trial_end, and writing it through would have erased the days the
account still had and locked it out.

Unpaid accounts are bounded by what they can cost us rather than by what they
can do: one workspace, one project, 3 GiB of direct uploads. YouTube imports,
share links, guests, comments and approvals stay unlimited, because those are
the parts worth trying and they cost nothing. isPaidTier() is the new seam;
hasBillingAccess() answers a different question now that access no longer
implies a card.

Signup CTAs, the pricing card, the comparison pages, the terms and the refund
policy all said the trial converts to a paid plan by itself. It no longer does,
so they say what happens instead. Settings and a banner name both dates that
matter: when the trial ends, and the fifteen days after that during which
nothing is deleted.

/admin/growth compares the two funnels on signup to paid within a fixed 30 day
window, not trial to paid. Dropping the card requirement multiplies trials, so
the old ratio can fall while more people actually pay, and reading it that way
would retire the change for the wrong reason.
2026-08-05 19:40:36 +03:00

426 lines
14 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();
});
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');
});
});