mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 09:36:08 +00:00
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.
473 lines
16 KiB
TypeScript
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');
|
|
});
|
|
});
|