diff --git a/apps/server/src/common/helpers/security-headers.spec.ts b/apps/server/src/common/helpers/security-headers.spec.ts new file mode 100644 index 00000000..ee74fec7 --- /dev/null +++ b/apps/server/src/common/helpers/security-headers.spec.ts @@ -0,0 +1,52 @@ +import { resolveFrameHeader } from './security-headers'; + +describe('resolveFrameHeader', () => { + describe('iframe embedding disabled (clickjacking protection)', () => { + it('returns X-Frame-Options SAMEORIGIN and ignores origins', () => { + expect(resolveFrameHeader(false, [])).toEqual({ + name: 'X-Frame-Options', + value: 'SAMEORIGIN', + }); + }); + + it('still returns X-Frame-Options even when origins are configured', () => { + // A wrong branch could leak a permissive CSP here; origins must be ignored + // when embedding is disabled so clickjacking protection stays intact. + const result = resolveFrameHeader(false, [ + 'https://a.com', + 'https://b.com', + ]); + expect(result).toEqual({ + name: 'X-Frame-Options', + value: 'SAMEORIGIN', + }); + expect(result?.name).not.toBe('Content-Security-Policy'); + }); + }); + + describe('iframe embedding allowed', () => { + it('returns null when there are no allowed origins', () => { + expect(resolveFrameHeader(true, [])).toBeNull(); + }); + + it('builds a frame-ancestors CSP for a single origin', () => { + expect(resolveFrameHeader(true, ['https://a.com'])).toEqual({ + name: 'Content-Security-Policy', + value: "frame-ancestors 'self' https://a.com", + }); + }); + + it('space-joins multiple origins after self', () => { + expect( + resolveFrameHeader(true, [ + 'https://a.com', + 'https://b.com', + 'https://c.com', + ]), + ).toEqual({ + name: 'Content-Security-Policy', + value: "frame-ancestors 'self' https://a.com https://b.com https://c.com", + }); + }); + }); +}); diff --git a/apps/server/src/common/helpers/utils.security.spec.ts b/apps/server/src/common/helpers/utils.security.spec.ts new file mode 100644 index 00000000..85c60a97 --- /dev/null +++ b/apps/server/src/common/helpers/utils.security.spec.ts @@ -0,0 +1,245 @@ +import { FastifyRequest } from 'fastify'; +import { + redactSensitiveUrl, + extractBearerTokenFromHeader, + parseRedisUrl, + normalizePostgresUrl, + diffAuditTrackedFields, + isUserDisabled, +} from './utils'; + +/** + * Build a minimal FastifyRequest-shaped object carrying just the authorization + * header, which is all extractBearerTokenFromHeader reads. + */ +function reqWithAuth(authorization?: string): FastifyRequest { + return { headers: { authorization } } as unknown as FastifyRequest; +} + +describe('redactSensitiveUrl', () => { + it('strips the query string from a sensitive (SSO) URL', () => { + expect( + redactSensitiveUrl('/api/sso/google/callback?code=secret&state=pii'), + ).toBe('/api/sso/google/callback'); + }); + + it('returns a sensitive URL unchanged when it has no query string', () => { + expect(redactSensitiveUrl('/api/sso/google/callback')).toBe( + '/api/sso/google/callback', + ); + }); + + it('does NOT strip the query string from a non-sensitive URL', () => { + // A mutation that redacts everything would break legitimate logging here. + expect(redactSensitiveUrl('/api/pages/list?page=2&token=abc')).toBe( + '/api/pages/list?page=2&token=abc', + ); + }); + + it('handles empty string without throwing and returns it unchanged', () => { + expect(redactSensitiveUrl('')).toBe(''); + }); + + it('handles undefined input without throwing', () => { + expect( + redactSensitiveUrl(undefined as unknown as string), + ).toBeUndefined(); + }); +}); + +describe('extractBearerTokenFromHeader', () => { + it('extracts the token from a Bearer scheme', () => { + expect(extractBearerTokenFromHeader(reqWithAuth('Bearer xyz'))).toBe('xyz'); + }); + + it('is case-insensitive on the scheme', () => { + // Impl lowercases the scheme before comparing, so lowercase "bearer" works. + expect(extractBearerTokenFromHeader(reqWithAuth('bearer xyz'))).toBe('xyz'); + expect(extractBearerTokenFromHeader(reqWithAuth('BEARER xyz'))).toBe('xyz'); + }); + + it('rejects a non-Bearer scheme (auth bypass guard)', () => { + expect( + extractBearerTokenFromHeader(reqWithAuth('Basic xyz')), + ).toBeUndefined(); + }); + + it('returns undefined when the header is missing', () => { + expect(extractBearerTokenFromHeader(reqWithAuth(undefined))).toBeUndefined(); + }); + + it('returns undefined for an empty header', () => { + expect(extractBearerTokenFromHeader(reqWithAuth(''))).toBeUndefined(); + }); + + it('returns undefined when the scheme has no token', () => { + expect( + extractBearerTokenFromHeader(reqWithAuth('Bearer')), + ).toBeUndefined(); + }); +}); + +describe('parseRedisUrl', () => { + it('parses a full URL into host/port/password/db/family', () => { + expect(parseRedisUrl('redis://user:pass@host:6379/3?family=6')).toEqual({ + host: 'host', + port: 6379, + password: 'pass', + db: 3, + family: 6, + }); + }); + + it('defaults db to 0 when there is no /db path segment', () => { + const cfg = parseRedisUrl('redis://localhost:6379'); + expect(cfg.db).toBe(0); + expect(cfg.host).toBe('localhost'); + expect(cfg.port).toBe(6379); + // No family query → undefined (not parsed). + expect(cfg.family).toBeUndefined(); + }); + + it('falls back to db 0 for a non-numeric db segment', () => { + expect(parseRedisUrl('redis://localhost:6379/abc').db).toBe(0); + }); + + it('returns an empty-string password when the URL has no credentials', () => { + // Quirk: WHATWG URL exposes a missing password as '' (empty string), + // not undefined, so the helper propagates ''. + const cfg = parseRedisUrl('redis://localhost:6379/1'); + expect(cfg.password).toBe(''); + expect(cfg.db).toBe(1); + }); +}); + +describe('normalizePostgresUrl', () => { + it('removes sslmode=no-verify but keeps other sslmode values', () => { + expect( + normalizePostgresUrl( + 'postgres://u:p@host:5432/db?sslmode=no-verify', + ), + ).toBe('postgres://u:p@host:5432/db'); + + expect( + normalizePostgresUrl('postgres://u:p@host:5432/db?sslmode=require'), + ).toBe('postgres://u:p@host:5432/db?sslmode=require'); + }); + + it('removes the schema param while preserving unrelated params', () => { + expect( + normalizePostgresUrl( + 'postgres://u:p@host:5432/db?schema=public&application_name=app', + ), + ).toBe('postgres://u:p@host:5432/db?application_name=app'); + }); + + it('returns a URL with no query string untouched', () => { + expect(normalizePostgresUrl('postgres://u:p@host:5432/db')).toBe( + 'postgres://u:p@host:5432/db', + ); + }); +}); + +describe('diffAuditTrackedFields', () => { + const fields = ['name', 'email', 'settings'] as const; + + it('returns a before/after entry for a changed tracked field', () => { + expect( + diffAuditTrackedFields( + fields, + { name: 'new' }, + { name: 'old' }, + { name: 'new' }, + ), + ).toEqual({ before: { name: 'old' }, after: { name: 'new' } }); + }); + + it('skips a field whose value is unchanged', () => { + expect( + diffAuditTrackedFields( + fields, + { name: 'same' }, + { name: 'same' }, + { name: 'same' }, + ), + ).toBeNull(); + }); + + it('skips a field that is absent from the dto (undefined guard)', () => { + // before/after differ, but the dto does not carry this field → not tracked. + expect( + diffAuditTrackedFields( + fields, + {}, + { name: 'old' }, + { name: 'new' }, + ), + ).toBeNull(); + }); + + it('returns null when nothing changed across all fields', () => { + expect( + diffAuditTrackedFields( + fields, + { name: 'a', email: 'b@x' }, + { name: 'a', email: 'b@x' }, + { name: 'a', email: 'b@x' }, + ), + ).toBeNull(); + }); + + it('treats null and undefined as equal (no false diff)', () => { + // before has explicit null, after omits the key (undefined) → both ?? null. + expect( + diffAuditTrackedFields( + fields, + { email: 'present' }, + { email: null }, + {}, + ), + ).toBeNull(); + }); + + it('compares object-valued fields structurally via JSON.stringify', () => { + // Distinct object references with equal contents must NOT register a diff. + expect( + diffAuditTrackedFields( + fields, + { settings: { theme: 'dark' } }, + { settings: { theme: 'dark' } }, + { settings: { theme: 'dark' } }, + ), + ).toBeNull(); + + expect( + diffAuditTrackedFields( + fields, + { settings: { theme: 'dark' } }, + { settings: { theme: 'light' } }, + { settings: { theme: 'dark' } }, + ), + ).toEqual({ + before: { settings: { theme: 'light' } }, + after: { settings: { theme: 'dark' } }, + }); + }); +}); + +describe('isUserDisabled', () => { + it('returns false for an active user', () => { + expect(isUserDisabled({ deactivatedAt: null, deletedAt: null })).toBe(false); + expect(isUserDisabled({})).toBe(false); + }); + + it('returns true for a deactivated user', () => { + expect( + isUserDisabled({ deactivatedAt: new Date('2026-01-01'), deletedAt: null }), + ).toBe(true); + }); + + it('returns true for a deleted user', () => { + expect( + isUserDisabled({ deactivatedAt: null, deletedAt: new Date('2026-01-01') }), + ).toBe(true); + }); +}); diff --git a/apps/server/src/core/auth/auth.util.spec.ts b/apps/server/src/core/auth/auth.util.spec.ts new file mode 100644 index 00000000..6fb0f2f2 --- /dev/null +++ b/apps/server/src/core/auth/auth.util.spec.ts @@ -0,0 +1,168 @@ +import { BadRequestException } from '@nestjs/common'; +import { createHmac } from 'node:crypto'; +import { + computeEmailSignature, + throwIfEmailNotVerified, + validateSsoEnforcement, + validateAllowedEmail, +} from './auth.util'; + +/** + * Pure-function contract for auth.util.ts. + * + * computeEmailSignature is the cross-surface coupling between the verify-email + * flow and the resend endpoint: the BadRequestException thrown on an unverified + * cloud login carries this signature so the client can request a resend without + * re-exposing the raw email. The signature must therefore be deterministic and + * lowercase-stable. The tests re-derive the expected HMAC independently with + * node:crypto so they fail if the input formatting drifts. + */ + +const APP_SECRET = 'unit-test-secret'; + +// Independently recompute the expected signature the way the implementation +// documents it: HMAC-SHA256 over `email.toLowerCase():workspaceId`. +function expectedSignature( + email: string, + workspaceId: string, + secret: string, +): string { + return createHmac('sha256', secret) + .update(`${email.toLowerCase()}:${workspaceId}`) + .digest('hex'); +} + +describe('computeEmailSignature', () => { + it('is deterministic: same inputs -> same hex', () => { + const a = computeEmailSignature('user@x.com', 'ws-1', APP_SECRET); + const b = computeEmailSignature('user@x.com', 'ws-1', APP_SECRET); + expect(a).toBe(b); + expect(a).toMatch(/^[0-9a-f]{64}$/); // sha256 hex + }); + + it('matches an independently computed HMAC-SHA256 of email.toLowerCase():workspaceId', () => { + const sig = computeEmailSignature('user@x.com', 'ws-1', APP_SECRET); + expect(sig).toBe(expectedSignature('user@x.com', 'ws-1', APP_SECRET)); + }); + + it('differs when the workspaceId differs', () => { + const a = computeEmailSignature('user@x.com', 'ws-1', APP_SECRET); + const b = computeEmailSignature('user@x.com', 'ws-2', APP_SECRET); + expect(a).not.toBe(b); + }); + + it('is case-insensitive on the email (User@x.com === user@x.com)', () => { + const upper = computeEmailSignature('User@x.com', 'ws-1', APP_SECRET); + const lower = computeEmailSignature('user@x.com', 'ws-1', APP_SECRET); + expect(upper).toBe(lower); + // And it equals the signature computed off the lowercased form. + expect(upper).toBe(expectedSignature('user@x.com', 'ws-1', APP_SECRET)); + }); +}); + +describe('throwIfEmailNotVerified', () => { + it('self-hosted (isCloud:false) -> never throws, even when unverified', () => { + expect(() => + throwIfEmailNotVerified({ + isCloud: false, + emailVerifiedAt: null, + email: 'user@x.com', + workspaceId: 'ws-1', + appSecret: APP_SECRET, + }), + ).not.toThrow(); + }); + + it('cloud + verified email -> never throws', () => { + expect(() => + throwIfEmailNotVerified({ + isCloud: true, + emailVerifiedAt: new Date(), + email: 'user@x.com', + workspaceId: 'ws-1', + appSecret: APP_SECRET, + }), + ).not.toThrow(); + }); + + it('cloud + unverified -> throws BadRequestException carrying the matching emailSignature', () => { + let caught: unknown; + try { + throwIfEmailNotVerified({ + isCloud: true, + emailVerifiedAt: null, + email: 'user@x.com', + workspaceId: 'ws-1', + appSecret: APP_SECRET, + }); + } catch (e) { + caught = e; + } + + expect(caught).toBeInstanceOf(BadRequestException); + const response = (caught as BadRequestException).getResponse() as { + message: string; + emailSignature: string; + }; + expect(response.emailSignature).toBe( + computeEmailSignature('user@x.com', 'ws-1', APP_SECRET), + ); + }); +}); + +describe('validateSsoEnforcement', () => { + it('throws BadRequestException when SSO is enforced', () => { + expect(() => + validateSsoEnforcement({ enforceSso: true } as never), + ).toThrow(BadRequestException); + }); + + it('returns without throwing when SSO is not enforced', () => { + expect(() => + validateSsoEnforcement({ enforceSso: false } as never), + ).not.toThrow(); + }); +}); + +describe('validateAllowedEmail', () => { + it('passes when the workspace has no email-domain restriction (empty array)', () => { + expect(() => + validateAllowedEmail('user@anywhere.com', { emailDomains: [] } as never), + ).not.toThrow(); + }); + + it('passes when emailDomains is undefined (no restriction)', () => { + expect(() => + validateAllowedEmail('user@anywhere.com', {} as never), + ).not.toThrow(); + }); + + it('passes when the email domain is allowed (case-insensitive match)', () => { + expect(() => + validateAllowedEmail('User@Example.COM', { + emailDomains: ['example.com'], + } as never), + ).not.toThrow(); + }); + + it('throws BadRequestException naming the domain when it is not allowed', () => { + let caught: unknown; + try { + validateAllowedEmail('user@evil.com', { + emailDomains: ['example.com'], + } as never); + } catch (e) { + caught = e; + } + expect(caught).toBeInstanceOf(BadRequestException); + expect((caught as BadRequestException).message).toContain('evil.com'); + }); + + // Latent bug: validateAllowedEmail does `userEmail.split('@')[1].toLowerCase()` + // with no guard, so an email without '@' throws a TypeError (cannot read + // 'toLowerCase' of undefined) instead of a clean validation error. Flagged + // rather than locked in as desired behaviour. + it.todo( + 'validateAllowedEmail should reject a malformed email without @ gracefully (currently throws TypeError - needs a guard)', + ); +}); diff --git a/apps/server/src/core/auth/guards/setup.guard.spec.ts b/apps/server/src/core/auth/guards/setup.guard.spec.ts new file mode 100644 index 00000000..c1e774d3 --- /dev/null +++ b/apps/server/src/core/auth/guards/setup.guard.spec.ts @@ -0,0 +1,77 @@ +import { ForbiddenException } from '@nestjs/common'; +import { SetupGuard } from './setup.guard'; + +/** + * Security contract for SetupGuard. + * + * /auth/setup creates the very first workspace + owner on a self-hosted + * instance. The guard is the only thing stopping that endpoint from being + * re-run to mint a SECOND owner on an already-initialised instance (privilege + * escalation), or from being reachable at all on cloud. It is constructed + * directly with a stubbed workspace repo and environment service. + * + * The guard's canActivate takes no ExecutionContext argument, so we call it + * with none. + */ + +function makeGuard(over: { + isCloud?: boolean; + workspaceCount?: number; +} = {}): { + guard: SetupGuard; + workspaceRepo: { count: jest.Mock }; + environmentService: { isCloud: jest.Mock }; +} { + const workspaceRepo = { + count: jest.fn().mockResolvedValue(over.workspaceCount ?? 0), + }; + const environmentService = { + isCloud: jest.fn().mockReturnValue(over.isCloud ?? false), + }; + + // Constructor signature (setup.guard.ts): (workspaceRepo, environmentService). + const guard = new (SetupGuard as unknown as new ( + ...args: unknown[] + ) => SetupGuard)(workspaceRepo, environmentService); + + return { guard, workspaceRepo, environmentService }; +} + +describe('SetupGuard.canActivate', () => { + it('cloud instance -> returns false (setup blocked) without checking the workspace count', async () => { + const { guard, workspaceRepo } = makeGuard({ isCloud: true }); + + await expect(guard.canActivate()).resolves.toBe(false); + // Short-circuits before touching the repo. + expect(workspaceRepo.count).not.toHaveBeenCalled(); + }); + + it('self-hosted with 0 existing workspaces -> returns true (first-time setup allowed)', async () => { + const { guard, workspaceRepo } = makeGuard({ + isCloud: false, + workspaceCount: 0, + }); + + await expect(guard.canActivate()).resolves.toBe(true); + expect(workspaceRepo.count).toHaveBeenCalledTimes(1); + }); + + it('self-hosted with an existing workspace -> throws ForbiddenException (no second owner)', async () => { + const { guard } = makeGuard({ isCloud: false, workspaceCount: 1 }); + + await expect(guard.canActivate()).rejects.toBeInstanceOf( + ForbiddenException, + ); + await expect(guard.canActivate()).rejects.toMatchObject({ + message: 'Workspace setup already completed.', + }); + }); + + it('self-hosted with many existing workspaces -> still throws ForbiddenException', async () => { + const { guard } = makeGuard({ isCloud: false, workspaceCount: 5 }); + + await expect(guard.canActivate()).rejects.toBeInstanceOf( + ForbiddenException, + ); + }); +}); diff --git a/apps/server/src/core/auth/services/token.service.behavior.spec.ts b/apps/server/src/core/auth/services/token.service.behavior.spec.ts new file mode 100644 index 00000000..32293c27 --- /dev/null +++ b/apps/server/src/core/auth/services/token.service.behavior.spec.ts @@ -0,0 +1,216 @@ +import { ForbiddenException, UnauthorizedException } from '@nestjs/common'; +import { TokenService } from './token.service'; +import { JwtType } from '../dto/jwt-payload'; + +/** + * Behaviour contract for TokenService. + * + * These are LIVE security tests: TokenService is constructed directly with a + * stubbed JwtService and EnvironmentService (the established direct-instantiation + * style — see verify-user-credentials.live.spec.ts). They exercise the real + * decision logic of the service: + * + * - verifyJwt enforces the token TYPE, blocking confused-deputy / token-type + * confusion (an attachment token must not be accepted as an access token). + * - generateAccessToken / generateCollabToken refuse to mint a token for a + * disabled (deactivated/deleted) user, and only stamp the non-spoofable + * `actor:'agent'` provenance claim when the caller explicitly supplies it — + * a forged actor claim would be a privilege escalation. + * - generateCollabToken uses the expected 24h expiry. + */ + +const APP_SECRET = 'test-app-secret'; + +function makeTokenService(over: { + sign?: jest.Mock; + verifyAsync?: jest.Mock; + getAppSecret?: jest.Mock; +} = {}): { + service: TokenService; + jwtService: { sign: jest.Mock; verifyAsync: jest.Mock }; + environmentService: { getAppSecret: jest.Mock }; +} { + const jwtService = { + // Sentinel return value so we can assert the token is whatever sign produced. + sign: over.sign ?? jest.fn().mockReturnValue('signed-token-sentinel'), + verifyAsync: over.verifyAsync ?? jest.fn(), + }; + const environmentService = { + getAppSecret: over.getAppSecret ?? jest.fn().mockReturnValue(APP_SECRET), + }; + + // Constructor signature (token.service.ts): (jwtService, environmentService). + const service = new (TokenService as unknown as new ( + ...args: unknown[] + ) => TokenService)(jwtService, environmentService); + + return { service, jwtService, environmentService }; +} + +// Minimal User-shaped object. Cast to any at call sites because the production +// User type carries many more fields we do not touch on these paths. +function makeUser(over: Record = {}) { + return { + id: 'user-1', + email: 'user@example.com', + workspaceId: 'ws-1', + deactivatedAt: null, + deletedAt: null, + ...over, + }; +} + +describe('TokenService.verifyJwt (token-type enforcement)', () => { + it('verifies the token with the app secret from EnvironmentService', async () => { + const verifyAsync = jest + .fn() + .mockResolvedValue({ type: JwtType.ACCESS, sub: 'user-1' }); + const { service, jwtService, environmentService } = makeTokenService({ + verifyAsync, + }); + + await service.verifyJwt('some.jwt.token', JwtType.ACCESS); + + expect(jwtService.verifyAsync).toHaveBeenCalledTimes(1); + expect(jwtService.verifyAsync).toHaveBeenCalledWith('some.jwt.token', { + secret: APP_SECRET, + }); + expect(environmentService.getAppSecret).toHaveBeenCalled(); + }); + + it('returns the payload when its type matches the expected type', async () => { + const payload = { type: JwtType.ACCESS, sub: 'user-1', workspaceId: 'ws-1' }; + const { service } = makeTokenService({ + verifyAsync: jest.fn().mockResolvedValue(payload), + }); + + const result = await service.verifyJwt('token', JwtType.ACCESS); + + expect(result).toBe(payload); + }); + + it('REJECTS a payload whose type does not match the expected type (no type confusion)', async () => { + // A genuine, correctly-signed attachment token must not pass as an access + // token. If the type guard were removed, this would resolve instead of throw. + const attachmentPayload = { type: JwtType.ATTACHMENT, attachmentId: 'a-1' }; + const { service } = makeTokenService({ + verifyAsync: jest.fn().mockResolvedValue(attachmentPayload), + }); + + await expect( + service.verifyJwt('token', JwtType.ACCESS), + ).rejects.toBeInstanceOf(UnauthorizedException); + await expect( + service.verifyJwt('token', JwtType.ACCESS), + ).rejects.toMatchObject({ + message: 'Invalid JWT token. Token type does not match.', + }); + }); +}); + +describe('TokenService.generateAccessToken', () => { + it('throws ForbiddenException and does NOT sign for a disabled (deactivated) user', async () => { + const { service, jwtService } = makeTokenService(); + const disabledUser = makeUser({ deactivatedAt: new Date() }); + + await expect( + service.generateAccessToken(disabledUser as never, 'session-1'), + ).rejects.toBeInstanceOf(ForbiddenException); + expect(jwtService.sign).not.toHaveBeenCalled(); + }); + + it('throws ForbiddenException and does NOT sign for a deleted user', async () => { + const { service, jwtService } = makeTokenService(); + const deletedUser = makeUser({ deletedAt: new Date() }); + + await expect( + service.generateAccessToken(deletedUser as never, 'session-1'), + ).rejects.toBeInstanceOf(ForbiddenException); + expect(jwtService.sign).not.toHaveBeenCalled(); + }); + + it('signs an ACCESS token with correct sub/workspaceId and NO actor claim by default', async () => { + const { service, jwtService } = makeTokenService(); + const user = makeUser({ id: 'user-42', workspaceId: 'ws-9' }); + + const token = await service.generateAccessToken(user as never, 'session-7'); + + expect(token).toBe('signed-token-sentinel'); + expect(jwtService.sign).toHaveBeenCalledTimes(1); + const payload = jwtService.sign.mock.calls[0][0]; + expect(payload).toMatchObject({ + sub: 'user-42', + workspaceId: 'ws-9', + type: JwtType.ACCESS, + sessionId: 'session-7', + }); + // The default (human) path must carry no provenance claim — a downstream + // 'user' actor is inferred from its absence. + expect(payload).not.toHaveProperty('actor'); + expect(payload).not.toHaveProperty('aiChatId'); + }); + + it('stamps actor:agent + aiChatId only when provenance is explicitly supplied', async () => { + const { service, jwtService } = makeTokenService(); + const user = makeUser({ id: 'user-42', workspaceId: 'ws-9' }); + + await service.generateAccessToken(user as never, 'session-7', { + actor: 'agent', + aiChatId: 'chat-123', + }); + + const payload = jwtService.sign.mock.calls[0][0]; + expect(payload).toMatchObject({ + sub: 'user-42', + type: JwtType.ACCESS, + actor: 'agent', + aiChatId: 'chat-123', + }); + }); +}); + +describe('TokenService.generateCollabToken', () => { + it('throws ForbiddenException and does NOT sign for a disabled user', async () => { + const { service, jwtService } = makeTokenService(); + const disabledUser = makeUser({ deactivatedAt: new Date() }); + + await expect( + service.generateCollabToken(disabledUser as never, 'ws-1'), + ).rejects.toBeInstanceOf(ForbiddenException); + expect(jwtService.sign).not.toHaveBeenCalled(); + }); + + it('signs a COLLAB token with a 24h expiry for a normal user', async () => { + const { service, jwtService } = makeTokenService(); + const user = makeUser({ id: 'user-3' }); + + await service.generateCollabToken(user as never, 'ws-77'); + + expect(jwtService.sign).toHaveBeenCalledTimes(1); + const [payload, options] = jwtService.sign.mock.calls[0]; + expect(payload).toMatchObject({ + sub: 'user-3', + workspaceId: 'ws-77', + type: JwtType.COLLAB, + }); + expect(payload).not.toHaveProperty('actor'); + expect(options).toEqual({ expiresIn: '24h' }); + }); + + it('stamps actor:agent + aiChatId on the collab token only when provenance is supplied', async () => { + const { service, jwtService } = makeTokenService(); + const user = makeUser({ id: 'user-3' }); + + await service.generateCollabToken(user as never, 'ws-77', { + actor: 'agent', + aiChatId: 'chat-456', + }); + + const [payload] = jwtService.sign.mock.calls[0]; + expect(payload).toMatchObject({ + type: JwtType.COLLAB, + actor: 'agent', + aiChatId: 'chat-456', + }); + }); +});