mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 09:36:08 +00:00
test: close the coverage gaps the first round left
Second pass over the suite, driven by the inventory in the gaps document. Nine agents wrote suites in parallel against private databases, then a tenth read all of it adversarially and five of its findings were fixed. unit + component 2076 -> 2079 (+888 over the round) api 647 -> 1015 e2e 18 -> 29 What was closed: - lib/route-access.ts, the page-level authorization layer, went from zero tests to 48. Every API route was guarded and none of the pages were. - The five media proxy routes now have a real 2xx beside every 403. The blocker was the positive control, solved by stubbing r2Client.send() and leaving lib/r2-media-proxy.ts itself real. - Every remaining server-side lib module: invitations, email verification, the upload tokens, the logger, request origin, the whole R2 and Bunny lifecycle, notifications and admin stats. - Six video-page hooks, and the chunking arithmetic extracted out of lib/client/r2-video-upload.ts as a pure module. - Five end-to-end flows: workspace members, bulk operations, the admin area, player interaction and failure recovery. Three things about the harness itself turned out to be wrong: - Two @/lib/r2 stubs in tests/setup/api.ts had the wrong return shape, so every route reaching finalizeR2VideoUpload silently took the "not a valid video" branch and no test noticed. - The auth matrix asserted only "not 2xx", which two entries satisfied without their guard existing. It now requires 401 or 403, which makes both load-bearing, and all 60 routes pass the stricter form. - Both admin API routes had no positive control anywhere: replacing their guard with an unconditional refusal left the entire suite green. Found by the adversarial review, now covered. Process: - bun run test:mutation runs StrykerJS over the authorization and validation modules. Diagnostic, not a gate, weekly in CI rather than on a push. - playwright.config.ts gains an opt-in webkit project for the player spec. - AGENTS.md now requires a batch of new tests to be reviewed by somebody who did not write them. Only two production files change, both deliberate: lib/auth.ts loses a verbatim copy of its own permission formulas, and lib/client/r2-video-upload.ts calls the extracted arithmetic. No behaviour change in either.
This commit is contained in:
@@ -0,0 +1,730 @@
|
||||
// lib/invitations.ts, exercised directly rather than through the two member
|
||||
// routes that call it. The routes decide who may invite; this module decides
|
||||
// what an invitation is worth once it is accepted, which is the part that hands
|
||||
// out standing access to a workspace or a project.
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import nodemailer from 'nodemailer';
|
||||
import { db } from '@/lib/db';
|
||||
import {
|
||||
acceptInvitationTokenForUser,
|
||||
acceptPendingInvitationsForUser,
|
||||
buildInvitationUrl,
|
||||
createOrRefreshInvitation,
|
||||
getValidInvitationByToken,
|
||||
sendInvitationEmail,
|
||||
} from '@/lib/invitations';
|
||||
import { mailTo, sentMail } from '../helpers/mail';
|
||||
import {
|
||||
addProjectMember,
|
||||
addWorkspaceMember,
|
||||
createInvitation,
|
||||
createUser,
|
||||
seedProject,
|
||||
} from '../factories';
|
||||
|
||||
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
const MINUTE_MS = 60 * 1000;
|
||||
|
||||
describe('createOrRefreshInvitation', () => {
|
||||
it('creates a pending invitation with a normalized address and a 32-byte token', async () => {
|
||||
const scenario = await seedProject();
|
||||
|
||||
const invitation = await createOrRefreshInvitation({
|
||||
email: ' [email protected] ',
|
||||
scope: 'WORKSPACE',
|
||||
role: 'COMMENTATOR',
|
||||
invitedById: scenario.owner.id,
|
||||
workspaceId: scenario.workspace.id,
|
||||
});
|
||||
|
||||
expect(invitation.email).toBe('[email protected]');
|
||||
expect(invitation.status).toBe('PENDING');
|
||||
expect(invitation.role).toBe('COMMENTATOR');
|
||||
expect(invitation.invitedById).toBe(scenario.owner.id);
|
||||
expect(invitation.workspaceId).toBe(scenario.workspace.id);
|
||||
expect(invitation.projectId).toBeNull();
|
||||
expect(invitation.acceptedAt).toBeNull();
|
||||
// randomBytes(32).toString('hex'). A short or non-random token here is the
|
||||
// whole security of the accept link.
|
||||
expect(invitation.token).toMatch(/^[0-9a-f]{64}$/);
|
||||
// The TTL is 7 days; allow a minute for the round trip.
|
||||
const ttl = invitation.expiresAt.getTime() - Date.now();
|
||||
expect(ttl).toBeGreaterThan(SEVEN_DAYS_MS - MINUTE_MS);
|
||||
expect(ttl).toBeLessThanOrEqual(SEVEN_DAYS_MS);
|
||||
});
|
||||
|
||||
it('refreshes the live invitation in place and rotates its token', async () => {
|
||||
const scenario = await seedProject();
|
||||
const args = {
|
||||
email: '[email protected]',
|
||||
scope: 'PROJECT' as const,
|
||||
invitedById: scenario.owner.id,
|
||||
projectId: scenario.project.id,
|
||||
};
|
||||
|
||||
const first = await createOrRefreshInvitation({ ...args, role: 'COMMENTATOR' });
|
||||
const second = await createOrRefreshInvitation({ ...args, role: 'ADMIN' });
|
||||
|
||||
expect(second.id).toBe(first.id);
|
||||
expect(await db.invitation.count()).toBe(1);
|
||||
// Re-inviting has to invalidate the link already in someone's inbox.
|
||||
expect(second.token).not.toBe(first.token);
|
||||
expect(second.role).toBe('ADMIN');
|
||||
expect(second.expiresAt.getTime()).toBeGreaterThanOrEqual(first.expiresAt.getTime());
|
||||
});
|
||||
|
||||
it('expires a stale pending invitation rather than reviving it', async () => {
|
||||
const scenario = await seedProject();
|
||||
const stale = await createInvitation({
|
||||
invitedById: scenario.owner.id,
|
||||
email: '[email protected]',
|
||||
scope: 'WORKSPACE',
|
||||
workspaceId: scenario.workspace.id,
|
||||
expiresAt: new Date(Date.now() - MINUTE_MS),
|
||||
});
|
||||
|
||||
const fresh = await createOrRefreshInvitation({
|
||||
email: '[email protected]',
|
||||
scope: 'WORKSPACE',
|
||||
role: 'COMMENTATOR',
|
||||
invitedById: scenario.owner.id,
|
||||
workspaceId: scenario.workspace.id,
|
||||
});
|
||||
|
||||
expect(fresh.id).not.toBe(stale.id);
|
||||
expect((await db.invitation.findUniqueOrThrow({ where: { id: stale.id } })).status).toBe(
|
||||
'EXPIRED'
|
||||
);
|
||||
expect(fresh.status).toBe('PENDING');
|
||||
expect(await db.invitation.count()).toBe(2);
|
||||
});
|
||||
|
||||
// Two concurrent invites can leave two live rows for one address. The next
|
||||
// call has to collapse them, or a cancelled invitation still has a working
|
||||
// twin in the database.
|
||||
it('leaves exactly one live invitation when duplicates already exist', async () => {
|
||||
const scenario = await seedProject();
|
||||
for (let i = 0; i < 2; i++) {
|
||||
await createInvitation({
|
||||
invitedById: scenario.owner.id,
|
||||
email: '[email protected]',
|
||||
scope: 'WORKSPACE',
|
||||
workspaceId: scenario.workspace.id,
|
||||
});
|
||||
}
|
||||
|
||||
const refreshed = await createOrRefreshInvitation({
|
||||
email: '[email protected]',
|
||||
scope: 'WORKSPACE',
|
||||
role: 'ADMIN',
|
||||
invitedById: scenario.owner.id,
|
||||
workspaceId: scenario.workspace.id,
|
||||
});
|
||||
|
||||
const pending = await db.invitation.findMany({ where: { status: 'PENDING' } });
|
||||
expect(pending.map((row) => row.id)).toEqual([refreshed.id]);
|
||||
expect(await db.invitation.count({ where: { status: 'CANCELED' } })).toBe(1);
|
||||
expect(await db.invitation.count()).toBe(2);
|
||||
});
|
||||
|
||||
it('keeps a workspace invitation and a project invitation for one address apart', async () => {
|
||||
const scenario = await seedProject();
|
||||
|
||||
const workspaceInvitation = await createOrRefreshInvitation({
|
||||
email: '[email protected]',
|
||||
scope: 'WORKSPACE',
|
||||
role: 'ADMIN',
|
||||
invitedById: scenario.owner.id,
|
||||
workspaceId: scenario.workspace.id,
|
||||
});
|
||||
const projectInvitation = await createOrRefreshInvitation({
|
||||
email: '[email protected]',
|
||||
scope: 'PROJECT',
|
||||
role: 'COMMENTATOR',
|
||||
invitedById: scenario.owner.id,
|
||||
projectId: scenario.project.id,
|
||||
});
|
||||
|
||||
expect(projectInvitation.id).not.toBe(workspaceInvitation.id);
|
||||
expect(await db.invitation.count({ where: { status: 'PENDING' } })).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getValidInvitationByToken', () => {
|
||||
it('returns the pending invitation behind a live token', async () => {
|
||||
const scenario = await seedProject();
|
||||
const invitation = await createInvitation({
|
||||
invitedById: scenario.owner.id,
|
||||
scope: 'PROJECT',
|
||||
projectId: scenario.project.id,
|
||||
token: 'live-token',
|
||||
});
|
||||
|
||||
expect((await getValidInvitationByToken('live-token'))?.id).toBe(invitation.id);
|
||||
});
|
||||
|
||||
it('returns null for an expired token', async () => {
|
||||
const scenario = await seedProject();
|
||||
await createInvitation({
|
||||
invitedById: scenario.owner.id,
|
||||
scope: 'PROJECT',
|
||||
projectId: scenario.project.id,
|
||||
token: 'stale-token',
|
||||
expiresAt: new Date(Date.now() - MINUTE_MS),
|
||||
});
|
||||
|
||||
expect(await getValidInvitationByToken('stale-token')).toBeNull();
|
||||
});
|
||||
|
||||
it.each(['ACCEPTED', 'CANCELED', 'EXPIRED'] as const)(
|
||||
'returns null for a %s invitation',
|
||||
async (status) => {
|
||||
const scenario = await seedProject();
|
||||
await createInvitation({
|
||||
invitedById: scenario.owner.id,
|
||||
scope: 'PROJECT',
|
||||
projectId: scenario.project.id,
|
||||
token: 'consumed-token',
|
||||
status,
|
||||
});
|
||||
|
||||
expect(await getValidInvitationByToken('consumed-token')).toBeNull();
|
||||
}
|
||||
);
|
||||
|
||||
it('returns null for a token nobody was ever given', async () => {
|
||||
expect(await getValidInvitationByToken('not-a-real-token')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('acceptInvitationTokenForUser', () => {
|
||||
// The role carried by the invitation is the only thing that decides the
|
||||
// membership role. A COMMENTATOR invite that lands as an ADMIN membership is
|
||||
// a silent privilege escalation, so both scopes are pinned in both roles.
|
||||
it.each([
|
||||
['COMMENTATOR', 'COMMENTATOR'],
|
||||
['ADMIN', 'ADMIN'],
|
||||
] as const)('a %s workspace invitation grants exactly %s', async (invitedRole, memberRole) => {
|
||||
const scenario = await seedProject();
|
||||
const invitee = await createUser({ email: '[email protected]' });
|
||||
const invitation = await createInvitation({
|
||||
invitedById: scenario.owner.id,
|
||||
email: '[email protected]',
|
||||
scope: 'WORKSPACE',
|
||||
workspaceId: scenario.workspace.id,
|
||||
role: invitedRole,
|
||||
});
|
||||
|
||||
const result = await acceptInvitationTokenForUser({
|
||||
token: invitation.token,
|
||||
userId: invitee.id,
|
||||
email: invitee.email!,
|
||||
});
|
||||
|
||||
expect(result).toBe('accepted');
|
||||
const membership = await db.workspaceMember.findUniqueOrThrow({
|
||||
where: { workspaceId_userId: { workspaceId: scenario.workspace.id, userId: invitee.id } },
|
||||
});
|
||||
expect(membership.role).toBe(memberRole);
|
||||
expect(await db.projectMember.count()).toBe(0);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['COMMENTATOR', 'COMMENTATOR'],
|
||||
['ADMIN', 'ADMIN'],
|
||||
] as const)('a %s project invitation grants exactly %s', async (invitedRole, memberRole) => {
|
||||
const scenario = await seedProject();
|
||||
const invitee = await createUser({ email: '[email protected]' });
|
||||
const invitation = await createInvitation({
|
||||
invitedById: scenario.owner.id,
|
||||
email: '[email protected]',
|
||||
scope: 'PROJECT',
|
||||
projectId: scenario.project.id,
|
||||
role: invitedRole,
|
||||
});
|
||||
|
||||
const result = await acceptInvitationTokenForUser({
|
||||
token: invitation.token,
|
||||
userId: invitee.id,
|
||||
email: invitee.email!,
|
||||
});
|
||||
|
||||
expect(result).toBe('accepted');
|
||||
const membership = await db.projectMember.findUniqueOrThrow({
|
||||
where: { projectId_userId: { projectId: scenario.project.id, userId: invitee.id } },
|
||||
});
|
||||
expect(membership.role).toBe(memberRole);
|
||||
expect(await db.workspaceMember.count()).toBe(0);
|
||||
});
|
||||
|
||||
it('consumes the invitation and stamps acceptedAt', async () => {
|
||||
const scenario = await seedProject();
|
||||
const invitee = await createUser({ email: '[email protected]' });
|
||||
const invitation = await createInvitation({
|
||||
invitedById: scenario.owner.id,
|
||||
email: '[email protected]',
|
||||
scope: 'PROJECT',
|
||||
projectId: scenario.project.id,
|
||||
});
|
||||
|
||||
await acceptInvitationTokenForUser({
|
||||
token: invitation.token,
|
||||
userId: invitee.id,
|
||||
email: invitee.email!,
|
||||
});
|
||||
|
||||
const stored = await db.invitation.findUniqueOrThrow({ where: { id: invitation.id } });
|
||||
expect(stored.status).toBe('ACCEPTED');
|
||||
expect(stored.acceptedAt).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it('refuses the same token a second time and leaves the membership as it was', async () => {
|
||||
const scenario = await seedProject();
|
||||
const invitee = await createUser({ email: '[email protected]' });
|
||||
const invitation = await createInvitation({
|
||||
invitedById: scenario.owner.id,
|
||||
email: '[email protected]',
|
||||
scope: 'PROJECT',
|
||||
projectId: scenario.project.id,
|
||||
role: 'COMMENTATOR',
|
||||
});
|
||||
const accept = () =>
|
||||
acceptInvitationTokenForUser({
|
||||
token: invitation.token,
|
||||
userId: invitee.id,
|
||||
email: invitee.email!,
|
||||
});
|
||||
|
||||
expect(await accept()).toBe('accepted');
|
||||
// Someone with the link should not be able to undo a later demotion by
|
||||
// replaying it, so the second accept must not reapply the invited role.
|
||||
await db.projectMember.update({
|
||||
where: { projectId_userId: { projectId: scenario.project.id, userId: invitee.id } },
|
||||
data: { role: 'ADMIN' },
|
||||
});
|
||||
|
||||
expect(await accept()).toBe('not_found');
|
||||
expect(await db.projectMember.count()).toBe(1);
|
||||
const membership = await db.projectMember.findUniqueOrThrow({
|
||||
where: { projectId_userId: { projectId: scenario.project.id, userId: invitee.id } },
|
||||
});
|
||||
expect(membership.role).toBe('ADMIN');
|
||||
});
|
||||
|
||||
// An invitation is bound to an address. A forwarded link must not let the
|
||||
// recipient walk into the project on their own account.
|
||||
it('refuses a token issued to a different address', async () => {
|
||||
const scenario = await seedProject();
|
||||
const bystander = await createUser({ email: '[email protected]' });
|
||||
const invitation = await createInvitation({
|
||||
invitedById: scenario.owner.id,
|
||||
email: '[email protected]',
|
||||
scope: 'PROJECT',
|
||||
projectId: scenario.project.id,
|
||||
});
|
||||
|
||||
const result = await acceptInvitationTokenForUser({
|
||||
token: invitation.token,
|
||||
userId: bystander.id,
|
||||
email: bystander.email!,
|
||||
});
|
||||
|
||||
expect(result).toBe('forbidden');
|
||||
expect(await db.projectMember.count()).toBe(0);
|
||||
expect((await db.invitation.findUniqueOrThrow({ where: { id: invitation.id } })).status).toBe(
|
||||
'PENDING'
|
||||
);
|
||||
});
|
||||
|
||||
it('matches the invited address case-insensitively and ignores stray whitespace', async () => {
|
||||
const scenario = await seedProject();
|
||||
const invitee = await createUser({ email: '[email protected]' });
|
||||
const invitation = await createInvitation({
|
||||
invitedById: scenario.owner.id,
|
||||
email: '[email protected]',
|
||||
scope: 'WORKSPACE',
|
||||
workspaceId: scenario.workspace.id,
|
||||
});
|
||||
|
||||
const result = await acceptInvitationTokenForUser({
|
||||
token: invitation.token,
|
||||
userId: invitee.id,
|
||||
email: ' [email protected] ',
|
||||
});
|
||||
|
||||
expect(result).toBe('accepted');
|
||||
expect(await db.workspaceMember.count()).toBe(1);
|
||||
});
|
||||
|
||||
it('reports an expired invitation as expired, flips the row, and grants nothing', async () => {
|
||||
const scenario = await seedProject();
|
||||
const invitee = await createUser({ email: '[email protected]' });
|
||||
const invitation = await createInvitation({
|
||||
invitedById: scenario.owner.id,
|
||||
email: '[email protected]',
|
||||
scope: 'PROJECT',
|
||||
projectId: scenario.project.id,
|
||||
expiresAt: new Date(Date.now() - MINUTE_MS),
|
||||
});
|
||||
|
||||
const result = await acceptInvitationTokenForUser({
|
||||
token: invitation.token,
|
||||
userId: invitee.id,
|
||||
email: invitee.email!,
|
||||
});
|
||||
|
||||
expect(result).toBe('expired');
|
||||
expect(await db.projectMember.count()).toBe(0);
|
||||
expect((await db.invitation.findUniqueOrThrow({ where: { id: invitation.id } })).status).toBe(
|
||||
'EXPIRED'
|
||||
);
|
||||
});
|
||||
|
||||
it.each(['ACCEPTED', 'CANCELED', 'EXPIRED'] as const)(
|
||||
'refuses a %s invitation without granting a membership',
|
||||
async (status) => {
|
||||
const scenario = await seedProject();
|
||||
const invitee = await createUser({ email: '[email protected]' });
|
||||
const invitation = await createInvitation({
|
||||
invitedById: scenario.owner.id,
|
||||
email: '[email protected]',
|
||||
scope: 'PROJECT',
|
||||
projectId: scenario.project.id,
|
||||
status,
|
||||
});
|
||||
|
||||
const result = await acceptInvitationTokenForUser({
|
||||
token: invitation.token,
|
||||
userId: invitee.id,
|
||||
email: invitee.email!,
|
||||
});
|
||||
|
||||
expect(result).toBe('not_found');
|
||||
expect(await db.projectMember.count()).toBe(0);
|
||||
expect((await db.invitation.findUniqueOrThrow({ where: { id: invitation.id } })).status).toBe(
|
||||
status
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
it('reports an unknown token as not_found', async () => {
|
||||
const user = await createUser();
|
||||
|
||||
const result = await acceptInvitationTokenForUser({
|
||||
token: 'not-a-real-token',
|
||||
userId: user.id,
|
||||
email: user.email!,
|
||||
});
|
||||
|
||||
expect(result).toBe('not_found');
|
||||
});
|
||||
|
||||
it('promotes an existing COMMENTATOR to ADMIN without duplicating the membership', async () => {
|
||||
const scenario = await seedProject();
|
||||
const member = await createUser({ email: '[email protected]' });
|
||||
await addProjectMember({
|
||||
projectId: scenario.project.id,
|
||||
userId: member.id,
|
||||
role: 'COMMENTATOR',
|
||||
});
|
||||
const invitation = await createInvitation({
|
||||
invitedById: scenario.owner.id,
|
||||
email: '[email protected]',
|
||||
scope: 'PROJECT',
|
||||
projectId: scenario.project.id,
|
||||
role: 'ADMIN',
|
||||
});
|
||||
|
||||
const result = await acceptInvitationTokenForUser({
|
||||
token: invitation.token,
|
||||
userId: member.id,
|
||||
email: member.email!,
|
||||
});
|
||||
|
||||
expect(result).toBe('accepted');
|
||||
expect(await db.projectMember.count()).toBe(1);
|
||||
const membership = await db.projectMember.findUniqueOrThrow({
|
||||
where: { projectId_userId: { projectId: scenario.project.id, userId: member.id } },
|
||||
});
|
||||
expect(membership.role).toBe('ADMIN');
|
||||
});
|
||||
|
||||
// The invited role wins over the role the member already holds, so accepting
|
||||
// a COMMENTATOR invitation demotes a sitting workspace ADMIN. Pinned because
|
||||
// it is a privilege change, and a surprising one: the accept link looks like
|
||||
// it can only ever add access.
|
||||
it('applies a COMMENTATOR invitation over an existing ADMIN membership', async () => {
|
||||
const scenario = await seedProject();
|
||||
const member = await createUser({ email: '[email protected]' });
|
||||
await addWorkspaceMember({
|
||||
workspaceId: scenario.workspace.id,
|
||||
userId: member.id,
|
||||
role: 'ADMIN',
|
||||
});
|
||||
const invitation = await createInvitation({
|
||||
invitedById: scenario.owner.id,
|
||||
email: '[email protected]',
|
||||
scope: 'WORKSPACE',
|
||||
workspaceId: scenario.workspace.id,
|
||||
role: 'COMMENTATOR',
|
||||
});
|
||||
|
||||
const result = await acceptInvitationTokenForUser({
|
||||
token: invitation.token,
|
||||
userId: member.id,
|
||||
email: member.email!,
|
||||
});
|
||||
|
||||
expect(result).toBe('accepted');
|
||||
const membership = await db.workspaceMember.findUniqueOrThrow({
|
||||
where: { workspaceId_userId: { workspaceId: scenario.workspace.id, userId: member.id } },
|
||||
});
|
||||
expect(membership.role).toBe('COMMENTATOR');
|
||||
});
|
||||
|
||||
// The owner already outranks any membership row. Writing one would put them
|
||||
// in their own member list and, for a COMMENTATOR invitation, next to a role
|
||||
// that reads as a demotion.
|
||||
it('gives the workspace owner no member row yet still consumes the invitation', async () => {
|
||||
const scenario = await seedProject();
|
||||
const invitation = await createInvitation({
|
||||
invitedById: scenario.owner.id,
|
||||
email: scenario.owner.email!,
|
||||
scope: 'WORKSPACE',
|
||||
workspaceId: scenario.workspace.id,
|
||||
role: 'COMMENTATOR',
|
||||
});
|
||||
|
||||
const result = await acceptInvitationTokenForUser({
|
||||
token: invitation.token,
|
||||
userId: scenario.owner.id,
|
||||
email: scenario.owner.email!,
|
||||
});
|
||||
|
||||
expect(result).toBe('accepted');
|
||||
expect(await db.workspaceMember.count()).toBe(0);
|
||||
expect((await db.invitation.findUniqueOrThrow({ where: { id: invitation.id } })).status).toBe(
|
||||
'ACCEPTED'
|
||||
);
|
||||
});
|
||||
|
||||
it('gives the project owner no member row yet still consumes the invitation', async () => {
|
||||
const scenario = await seedProject();
|
||||
const invitation = await createInvitation({
|
||||
invitedById: scenario.owner.id,
|
||||
email: scenario.owner.email!,
|
||||
scope: 'PROJECT',
|
||||
projectId: scenario.project.id,
|
||||
role: 'ADMIN',
|
||||
});
|
||||
|
||||
const result = await acceptInvitationTokenForUser({
|
||||
token: invitation.token,
|
||||
userId: scenario.owner.id,
|
||||
email: scenario.owner.email!,
|
||||
});
|
||||
|
||||
expect(result).toBe('accepted');
|
||||
expect(await db.projectMember.count()).toBe(0);
|
||||
expect((await db.invitation.findUniqueOrThrow({ where: { id: invitation.id } })).status).toBe(
|
||||
'ACCEPTED'
|
||||
);
|
||||
});
|
||||
|
||||
// Pins today's behaviour for a malformed row (scope WORKSPACE with no
|
||||
// workspaceId): the caller is told "accepted" while nothing is granted and
|
||||
// the invitation stays PENDING, so the accept page shows a success screen.
|
||||
// See the report accompanying this suite.
|
||||
it('reports accepted for a scoped invitation that points at nothing', async () => {
|
||||
const scenario = await seedProject();
|
||||
const invitee = await createUser({ email: '[email protected]' });
|
||||
const invitation = await createInvitation({
|
||||
invitedById: scenario.owner.id,
|
||||
email: '[email protected]',
|
||||
scope: 'WORKSPACE',
|
||||
workspaceId: null,
|
||||
});
|
||||
|
||||
const result = await acceptInvitationTokenForUser({
|
||||
token: invitation.token,
|
||||
userId: invitee.id,
|
||||
email: invitee.email!,
|
||||
});
|
||||
|
||||
expect(result).toBe('accepted');
|
||||
expect(await db.workspaceMember.count()).toBe(0);
|
||||
expect((await db.invitation.findUniqueOrThrow({ where: { id: invitation.id } })).status).toBe(
|
||||
'PENDING'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('acceptPendingInvitationsForUser', () => {
|
||||
it('applies every live invitation for the address with the role each one carries', async () => {
|
||||
const scenario = await seedProject();
|
||||
const invitee = await createUser({ email: '[email protected]' });
|
||||
const workspaceInvitation = await createInvitation({
|
||||
invitedById: scenario.owner.id,
|
||||
email: '[email protected]',
|
||||
scope: 'WORKSPACE',
|
||||
workspaceId: scenario.workspace.id,
|
||||
role: 'COMMENTATOR',
|
||||
});
|
||||
const projectInvitation = await createInvitation({
|
||||
invitedById: scenario.owner.id,
|
||||
email: '[email protected]',
|
||||
scope: 'PROJECT',
|
||||
projectId: scenario.project.id,
|
||||
role: 'ADMIN',
|
||||
});
|
||||
|
||||
await acceptPendingInvitationsForUser(invitee.id, ' [email protected] ');
|
||||
|
||||
const workspaceMembership = await db.workspaceMember.findUniqueOrThrow({
|
||||
where: { workspaceId_userId: { workspaceId: scenario.workspace.id, userId: invitee.id } },
|
||||
});
|
||||
const projectMembership = await db.projectMember.findUniqueOrThrow({
|
||||
where: { projectId_userId: { projectId: scenario.project.id, userId: invitee.id } },
|
||||
});
|
||||
expect(workspaceMembership.role).toBe('COMMENTATOR');
|
||||
expect(projectMembership.role).toBe('ADMIN');
|
||||
for (const id of [workspaceInvitation.id, projectInvitation.id]) {
|
||||
expect((await db.invitation.findUniqueOrThrow({ where: { id } })).status).toBe('ACCEPTED');
|
||||
}
|
||||
});
|
||||
|
||||
it('expires the stale invitations instead of granting them', async () => {
|
||||
const scenario = await seedProject();
|
||||
const invitee = await createUser({ email: '[email protected]' });
|
||||
const stale = await createInvitation({
|
||||
invitedById: scenario.owner.id,
|
||||
email: '[email protected]',
|
||||
scope: 'PROJECT',
|
||||
projectId: scenario.project.id,
|
||||
expiresAt: new Date(Date.now() - MINUTE_MS),
|
||||
});
|
||||
|
||||
await acceptPendingInvitationsForUser(invitee.id, invitee.email!);
|
||||
|
||||
expect(await db.projectMember.count()).toBe(0);
|
||||
expect((await db.invitation.findUniqueOrThrow({ where: { id: stale.id } })).status).toBe(
|
||||
'EXPIRED'
|
||||
);
|
||||
});
|
||||
|
||||
it('ignores invitations addressed to somebody else', async () => {
|
||||
const scenario = await seedProject();
|
||||
const invitee = await createUser({ email: '[email protected]' });
|
||||
const other = await createInvitation({
|
||||
invitedById: scenario.owner.id,
|
||||
email: '[email protected]',
|
||||
scope: 'PROJECT',
|
||||
projectId: scenario.project.id,
|
||||
role: 'ADMIN',
|
||||
});
|
||||
|
||||
await acceptPendingInvitationsForUser(invitee.id, invitee.email!);
|
||||
|
||||
expect(await db.projectMember.count()).toBe(0);
|
||||
expect((await db.invitation.findUniqueOrThrow({ where: { id: other.id } })).status).toBe(
|
||||
'PENDING'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildInvitationUrl', () => {
|
||||
it('points at /invitations/accept on the configured origin', () => {
|
||||
vi.stubEnv('NEXTAUTH_URL', 'https://app.example.test');
|
||||
|
||||
expect(buildInvitationUrl('abc123')).toBe(
|
||||
'https://app.example.test/invitations/accept?token=abc123'
|
||||
);
|
||||
});
|
||||
|
||||
it('escapes a token that carries query syntax', () => {
|
||||
vi.stubEnv('NEXTAUTH_URL', 'https://app.example.test');
|
||||
|
||||
expect(buildInvitationUrl('a b&c=d')).toBe(
|
||||
'https://app.example.test/invitations/accept?token=a+b%26c%3Dd'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sendInvitationEmail', () => {
|
||||
const invite = {
|
||||
to: '[email protected]',
|
||||
inviterName: 'Ada Lovelace',
|
||||
role: 'COMMENTATOR' as const,
|
||||
scope: 'WORKSPACE' as const,
|
||||
targetName: 'Acme',
|
||||
invitationUrl: 'https://app.example.test/invitations/accept?token=abc123',
|
||||
};
|
||||
|
||||
it('mails the invited address a message carrying the accept link', async () => {
|
||||
vi.stubEnv('SMTP_FROM', 'OpenFrame <[email protected]>');
|
||||
|
||||
expect(await sendInvitationEmail(invite)).toBe(true);
|
||||
|
||||
const mails = mailTo('[email protected]');
|
||||
expect(mails).toHaveLength(1);
|
||||
expect(mails[0].from).toBe('OpenFrame <[email protected]>');
|
||||
expect(mails[0].subject).toBe('[OpenFrame] You were invited to a workspace: Acme');
|
||||
expect(mails[0].html).toContain('https://app.example.test/invitations/accept?token=abc123');
|
||||
expect(mails[0].html).toContain('Ada Lovelace');
|
||||
expect(mails[0].html).toContain('Commentator');
|
||||
});
|
||||
|
||||
it('names the project scope and the Admin role in a project admin invitation', async () => {
|
||||
expect(
|
||||
await sendInvitationEmail({
|
||||
...invite,
|
||||
scope: 'PROJECT',
|
||||
role: 'ADMIN',
|
||||
targetName: 'Launch Film',
|
||||
})
|
||||
).toBe(true);
|
||||
|
||||
const mails = mailTo('[email protected]');
|
||||
expect(mails[0].subject).toBe('[OpenFrame] You were invited to a project: Launch Film');
|
||||
expect(mails[0].html).toContain('Admin');
|
||||
expect(mails[0].html).not.toContain('Commentator');
|
||||
});
|
||||
|
||||
// The inviter's display name and the target name are user-supplied and land
|
||||
// in an HTML mail body.
|
||||
it('escapes markup in the inviter and target names', async () => {
|
||||
await sendInvitationEmail({
|
||||
...invite,
|
||||
inviterName: '<script>alert(1)</script>',
|
||||
targetName: '<img src=x onerror=alert(2)>',
|
||||
});
|
||||
|
||||
const html = mailTo('[email protected]')[0].html!;
|
||||
expect(html).not.toContain('<script>');
|
||||
expect(html).not.toContain('<img src=x');
|
||||
expect(html).toContain('<script>');
|
||||
});
|
||||
|
||||
// The member routes call this with `void`, so a rejection here would surface
|
||||
// as an unhandled rejection rather than as a failed invite.
|
||||
it('reports failure instead of throwing when the transport rejects', async () => {
|
||||
vi.mocked(nodemailer.createTransport).mockReturnValueOnce({
|
||||
sendMail: vi.fn(async () => {
|
||||
throw new Error('smtp is down');
|
||||
}),
|
||||
} as unknown as ReturnType<typeof nodemailer.createTransport>);
|
||||
|
||||
expect(await sendInvitationEmail(invite)).toBe(false);
|
||||
expect(sentMail()).toEqual([]);
|
||||
});
|
||||
|
||||
it('sends nothing and reports failure when SMTP is not configured', async () => {
|
||||
vi.stubEnv('SMTP_HOST', '');
|
||||
vi.stubEnv('SMTP_USER', '');
|
||||
vi.stubEnv('SMTP_PASSWORD', '');
|
||||
|
||||
expect(await sendInvitationEmail(invite)).toBe(false);
|
||||
expect(sentMail()).toEqual([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user