test(server): add behavioural unit tests for auth + common security helpers
Batch 1 of the test-strategy rollout. Fills the highest-value gaps where existing specs were only `toBeDefined()` smoke tests or absent. Test-only, no production source touched. - token.service.behavior.spec.ts: verifyJwt type-mismatch rejection (confused deputy), generateAccessToken/generateCollabToken disabled-user -> Forbidden, agent `actor` claim only from signed provenance, correct expiry. - auth.util.spec.ts: computeEmailSignature (stable HMAC, case-normalized), throwIfEmailNotVerified, validateSsoEnforcement, validateAllowedEmail; it.todo flags the unguarded `@`-less email TypeError. - guards/setup.guard.spec.ts: cloud blocks setup, first-run allows, re-run on an initialised instance is forbidden (privilege escalation guard). - security-headers.spec.ts: resolveFrameHeader clickjacking/CSP branches. - utils.security.spec.ts: redactSensitiveUrl, extractBearerTokenFromHeader, parseRedisUrl, normalizePostgresUrl, diffAuditTrackedFields, isUserDisabled. 60 tests + 1 todo, all green. Reviewed for mutation resistance. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
52
apps/server/src/common/helpers/security-headers.spec.ts
Normal file
52
apps/server/src/common/helpers/security-headers.spec.ts
Normal file
@@ -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",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
245
apps/server/src/common/helpers/utils.security.spec.ts
Normal file
245
apps/server/src/common/helpers/utils.security.spec.ts
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
168
apps/server/src/core/auth/auth.util.spec.ts
Normal file
168
apps/server/src/core/auth/auth.util.spec.ts
Normal file
@@ -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)',
|
||||||
|
);
|
||||||
|
});
|
||||||
77
apps/server/src/core/auth/guards/setup.guard.spec.ts
Normal file
77
apps/server/src/core/auth/guards/setup.guard.spec.ts
Normal file
@@ -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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string, unknown> = {}) {
|
||||||
|
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',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user