Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 25 additions & 2 deletions packages/backend/src/adapters/adapters.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,13 @@ function buildController() {
};
const licenseGuard = {
checkCanCreateConnector: jest.fn().mockResolvedValue(undefined),
getUsage: jest.fn().mockResolvedValue({
plan: 'trial',
connectors: { current: 0, max: 2, isOver: false },
mcpServers: { current: 0, max: 2, isOver: false },
users: { current: 1, max: 1, isOver: false },
isOverAny: false,
}),
};
const mcpServers = {
attachToDefaultServer: jest
Expand Down Expand Up @@ -98,12 +105,28 @@ describe('AdaptersController — starter pack', () => {
return built;
}

it('serves the pack for the caller workspace', async () => {
it('serves the pack plus the plan connector allowance for the caller workspace', async () => {
const { controller, adaptersService } = withPack();
await expect(controller.starterPack(req('ADMIN'))).resolves.toEqual(pack);
await expect(controller.starterPack(req('ADMIN'))).resolves.toEqual({
items: pack,
connectors: { current: 0, max: 2, remaining: 2 },
});
expect((adaptersService as any).starterPack).toHaveBeenCalledWith('org1');
});

it('reports an uncapped plan as unlimited remaining', async () => {
const { controller, licenseGuard } = withPack();
licenseGuard.getUsage.mockResolvedValueOnce({
plan: 'business',
connectors: { current: 3, max: null, isOver: false },
mcpServers: { current: 1, max: null, isOver: false },
users: { current: 1, max: 10, isOver: false },
isOverAny: false,
});
const res = await controller.starterPack(req('ADMIN'));
expect(res.connectors).toEqual({ current: 3, max: null, remaining: null });
});

it('rejects a VIEWER before installing anything', async () => {
const { controller, adaptersService } = withPack();
await expect(
Expand Down
25 changes: 22 additions & 3 deletions packages/backend/src/adapters/adapters.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,29 @@ export class AdaptersController {
@ApiOperation({
summary: 'Connectors offered to a new workspace',
description:
'Keyless connectors that install in one click, with whether this workspace already has each one.',
'Keyless connectors that install in one click, with whether this workspace already has each one, ' +
'plus the connector allowance of the current plan so the page never preselects more than will fit.',
})
starterPack(@Req() req: any) {
return this.adaptersService.starterPack(req.user.organizationId);
async starterPack(@Req() req: any) {
const [items, usage] = await Promise.all([
this.adaptersService.starterPack(req.user.organizationId),
// Usage is cloud/licence-specific; on a plan with no cap `max` is null.
this.licenseGuard
.getUsage(req.user.sub, req.user.organizationId)
.catch(() => null),
]);
const current = usage?.connectors.current ?? 0;
const max = usage?.connectors.max ?? null;
return {
items,
// `remaining` is null when the plan is uncapped; otherwise how many more
// connectors this workspace can add right now (never negative).
connectors: {
current,
max,
remaining: max == null ? null : Math.max(0, max - current),
},
};
}

@Post('starter-pack/install')
Expand Down
163 changes: 161 additions & 2 deletions packages/backend/src/auth/auth.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,18 @@ function makeController({
const sent: string[] = [];
const tokens: { userId: string; createdAt: Date; usedAt: Date | null }[] = [];
const events: any[] = [];
const invites: any[] = [];

const authService = {
hashPassword: jest.fn(async (p: string) => `hash:${p}`),
comparePassword: jest.fn(async (p: string, h: string) => h === `hash:${p}`),
generateToken: jest.fn(() => 'jwt'),
// Test sessions are the literal string `sess:<email>`; anything else is an
// invalid token.
verifyToken: jest.fn((t: string) => {
if (typeof t === 'string' && t.startsWith('sess:')) return { email: t.slice(5) };
throw new Error('invalid token');
}),
};
const usersService = {
findByEmail: jest.fn(async (email: string) => users.find((u) => u.email === email) ?? null),
Expand All @@ -62,6 +69,11 @@ function makeController({
users.push(u);
return u;
}),
update: jest.fn(async (id: string, data: any) => {
const u = users.find((x) => x.id === id);
if (u) Object.assign(u, data);
return u;
}),
};
const prisma = {
organization: { findFirst: jest.fn(async () => ({ id: 'org-first' })) },
Expand All @@ -78,6 +90,19 @@ function makeController({
),
},
passwordResetToken: { create: jest.fn(async ({ data }: any) => data) },
invitationToken: {
findUnique: jest.fn(async ({ where }: any) => invites.find((i) => i.token === where.token) ?? null),
findFirst: jest.fn(async () => null),
create: jest.fn(async ({ data }: any) => {
invites.push({ ...data });
return data;
}),
update: jest.fn(async ({ where, data }: any) => {
const inv = invites.find((i) => i.token === where.token || i.id === where.id);
if (inv) Object.assign(inv, data);
return inv;
}),
},
productEvent: {
create: jest.fn(async ({ data }: any) => {
events.push(data);
Expand Down Expand Up @@ -111,8 +136,14 @@ function makeController({
const organizationsService = {
create: jest.fn(async () => ({ id: `org-${users.length + 1}` })),
addMember: jest.fn(async () => undefined),
getMembership: jest.fn(async () => null),
switchOrg: jest.fn(async (userId: string, organizationId: string) => {
const u = users.find((x) => x.id === userId);
return { ...(u ?? {}), organizationId };
}),
};
const mcpServersService = { createDefaultForUser: jest.fn(async () => undefined) };
const rolesService = { setUserRoles: jest.fn(async () => undefined) };
const ssoEnforcement = { isPasswordLoginBlocked: jest.fn(async () => false) };

const controller = new AuthController(
Expand All @@ -129,14 +160,25 @@ function makeController({
getLatestInactiveLicense: jest.fn(async () => orgEndedLicense),
} as any, // licenseService
{} as any, // securityEvents
{} as any, // rolesService
rolesService as any, // rolesService
{} as any, // recoveryCodes
ssoEnforcement as any,
// The real service, so the test sees what would actually be stored.
new ProductEventService(prisma as any),
{ assertSeatAvailable: jest.fn(async () => undefined), getState: jest.fn(async () => ({ trialAvailable: true })) } as any, // edition
);
return { controller, users, sent, events, authService, usersService, emailService, prisma };
return {
controller,
users,
sent,
events,
invites,
authService,
usersService,
emailService,
organizationsService,
prisma,
};
}

const flush = () => new Promise((resolve) => setImmediate(resolve));
Expand Down Expand Up @@ -350,3 +392,120 @@ describe('AuthController.login — cloud licence setup', () => {
expect((await login(controller)).needsLicenseSetup).toBe(true);
});
});

/**
* An invitation link is not proof that whoever holds it controls the invited
* address. Accepting an invite for an address that ALREADY has an account must
* require that account's password (or a live session for it) — otherwise any
* admin (i.e. any signed-up user) could invite an arbitrary address and take
* the account over by accepting the invite themselves.
*/
describe('AuthController — accept-invite ownership check', () => {
const makeInvite = (over: Partial<any> = {}) => ({
id: 'inv1',
token: 'invite-token',
email: 'taken@example.com',
role: 'EDITOR',
mcpRoleId: null,
mcpRoleIds: [],
organizationId: 'org-new',
expiresAt: new Date(Date.now() + 3600_000),
usedAt: null,
...over,
});

it('refuses to attach an existing account without the right password', async () => {
const a = makeController({ mode: 'cloud', accounts: [EXISTING] });
a.invites.push(makeInvite());
await expect(
a.controller.acceptInvite({ headers: {} }, { token: 'invite-token', password: 'Wrong#Pass9' }),
).rejects.toBeInstanceOf(UnauthorizedException);
expect(a.organizationsService.addMember).not.toHaveBeenCalled();
});

it('refuses with no password at all', async () => {
const a = makeController({ mode: 'cloud', accounts: [EXISTING] });
a.invites.push(makeInvite());
await expect(
a.controller.acceptInvite({ headers: {} }, { token: 'invite-token' }),
).rejects.toBeInstanceOf(UnauthorizedException);
expect(a.organizationsService.addMember).not.toHaveBeenCalled();
});

it('accepts an existing account with its correct password', async () => {
const a = makeController({ mode: 'cloud', accounts: [EXISTING] });
a.invites.push(makeInvite());
const res = await a.controller.acceptInvite(
{ headers: {} },
{ token: 'invite-token', password: 'Correct#Horse1' },
);
expect(res.accessToken).toBe('jwt');
expect(a.organizationsService.addMember).toHaveBeenCalledWith(
'u-existing',
'org-new',
'EDITOR',
);
});

it('accepts an existing account with a live session for the same address, no password', async () => {
const a = makeController({ mode: 'cloud', accounts: [EXISTING] });
a.invites.push(makeInvite());
const res = await a.controller.acceptInvite(
{ headers: { authorization: 'Bearer sess:taken@example.com' } },
{ token: 'invite-token' },
);
expect(res.accessToken).toBe('jwt');
expect(a.organizationsService.addMember).toHaveBeenCalled();
});

it('ignores a session that belongs to a different address', async () => {
const a = makeController({ mode: 'cloud', accounts: [EXISTING] });
a.invites.push(makeInvite());
await expect(
a.controller.acceptInvite(
{ headers: { authorization: 'Bearer sess:attacker@example.com' } },
{ token: 'invite-token' },
),
).rejects.toBeInstanceOf(UnauthorizedException);
expect(a.organizationsService.addMember).not.toHaveBeenCalled();
});

it('tells an SSO-only existing account to sign in first (password cannot prove it)', async () => {
const sso: Account = { ...EXISTING, passwordHash: null };
const a = makeController({ mode: 'cloud', accounts: [sso] });
a.invites.push(makeInvite());
await expect(
a.controller.acceptInvite({ headers: {} }, { token: 'invite-token', password: 'anything' }),
).rejects.toBeInstanceOf(UnauthorizedException);
});

it('creates a brand-new account from an invite (no ownership check needed)', async () => {
const a = makeController({ mode: 'cloud', accounts: [] });
a.invites.push(makeInvite({ email: 'fresh@example.com' }));
const res = await a.controller.acceptInvite(
{ headers: {} },
{ token: 'invite-token', password: 'Brand#New123', name: 'Fresh' },
);
expect(res.accessToken).toBe('jwt');
expect(a.users.map((u) => u.email)).toContain('fresh@example.com');
});

it('rejects a weak password for a brand-new account', async () => {
const a = makeController({ mode: 'cloud', accounts: [] });
a.invites.push(makeInvite({ email: 'fresh@example.com' }));
await expect(
a.controller.acceptInvite(
{ headers: {} },
{ token: 'invite-token', password: 'weak', name: 'Fresh' },
),
).rejects.toBeTruthy();
});

it('verifyInvite reports whether the address already exists', async () => {
const a = makeController({ mode: 'cloud', accounts: [EXISTING] });
a.invites.push(makeInvite());
a.invites.push(makeInvite({ id: 'inv2', token: 't2', email: 'fresh@example.com' }));
expect(await a.controller.verifyInvite('invite-token')).toMatchObject({ exists: true });
expect(await a.controller.verifyInvite('t2')).toMatchObject({ exists: false });
});
});
Loading
Loading