diff --git a/server/src/nest/auth/auth.controller.ts b/server/src/nest/auth/auth.controller.ts index d39bee688..5778d3b62 100644 --- a/server/src/nest/auth/auth.controller.ts +++ b/server/src/nest/auth/auth.controller.ts @@ -6,7 +6,7 @@ import { CurrentUser } from '../auth-core/current-user.decorator'; import { JwtAuthGuard } from '../auth-core/jwt-auth.guard'; import { currentSessionId, decodeSessionClaims } from '../auth-core/jwt-verify'; import { MfaExempt } from '../auth-core/mfa-policy.guard'; -import { isDemoWriteBlocked, DEMO_WRITE_ERROR } from '../common/demo-write'; +import { DEMO_ACCOUNT_REFUSAL, DEMO_WRITE_ERROR, isDemoWriteBlocked, refuseForDemoAccount } from '../common/demo-write'; import { ManagedForbidden } from '../common/managed'; import { RateLimitService } from '../common/rate-limit.service'; import { sessionClientFrom } from '../sessions/sessions.service'; @@ -194,6 +194,11 @@ export class AuthController { @Put('me/settings') async updateSettings(@CurrentUser() user: User, @Body() body: SettingsUpdateDto, @Req() req: Request) { + // The name and the address belong to the Account tab. Every visitor of a + // demo instance shares this account, so they stay what the demo seeded. + if (body.username !== undefined || body.email !== undefined) { + refuseForDemoAccount(user, DEMO_ACCOUNT_REFUSAL.account); + } const result = await this.profile.updateSettings(user.id, body); await this.auditApiKeys(user.id, result.changedKeys ?? [], req); // The address itself stays out of the row, like the key values above; the row @@ -228,6 +233,7 @@ export class AuthController { @Delete('avatar') async deleteAvatar(@CurrentUser() user: User) { + refuseForDemoAccount(user, DEMO_ACCOUNT_REFUSAL.account); return this.profile.deleteAvatar(user.id); } @@ -312,6 +318,7 @@ export class AuthController { @Post('mcp-tokens') @HttpCode(201) async createMcpToken(@CurrentUser() user: User, @Body() body: McpTokenCreateDto, @Req() req: Request) { + refuseForDemoAccount(user, DEMO_ACCOUNT_REFUSAL.mcp); await this.limit('login', req, 5); const result = await this.tokens.createMcpToken(user.id, body.name); return { token: result.token }; @@ -319,6 +326,7 @@ export class AuthController { @Delete('mcp-tokens/:id') async deleteMcpToken(@CurrentUser() user: User, @Param('id') id: string) { + refuseForDemoAccount(user, DEMO_ACCOUNT_REFUSAL.mcp); await this.tokens.deleteMcpToken(user.id, id); return { success: true }; } @@ -343,6 +351,7 @@ export class AuthController { @Post('api-tokens') @HttpCode(201) async createApiToken(@CurrentUser() user: User, @Body() body: ApiTokenCreateDto, @Req() req: Request) { + refuseForDemoAccount(user, DEMO_ACCOUNT_REFUSAL.apiKeys); await this.limit('login', req, 5); // No `scopes` means the key reads everything, which is what every key minted // before this field existed does. Narrowing stays opt-in so the change diff --git a/server/src/nest/auth/passkey.controller.ts b/server/src/nest/auth/passkey.controller.ts index f3cd44ef7..60d5cab44 100644 --- a/server/src/nest/auth/passkey.controller.ts +++ b/server/src/nest/auth/passkey.controller.ts @@ -5,6 +5,7 @@ import { CurrentUser } from '../auth-core/current-user.decorator'; import { JwtAuthGuard } from '../auth-core/jwt-auth.guard'; import { MfaExempt } from '../auth-core/mfa-policy.guard'; import { setAuthCookie } from '../common/cookie'; +import { DEMO_ACCOUNT_REFUSAL, refuseForDemoAccount } from '../common/demo-write'; import { RateLimitService } from '../common/rate-limit.service'; import { sessionClientFrom } from '../sessions/sessions.service'; import { @@ -69,6 +70,9 @@ export class PasskeyController { @HttpCode(200) @UseGuards(PasskeyEnabledGuard, JwtAuthGuard) async registerOptions(@CurrentUser() user: User, @Body() body: PasskeyRegisterOptionsDto, @Req() req: Request) { + // Every visitor shares the demo account: a passkey enrolled on it would be + // one stranger's key to everybody's demo. + refuseForDemoAccount(user, DEMO_ACCOUNT_REFUSAL.passkeys); await this.limit('mfa', req, 5); // The Origin header is only a pre-ceremony sanity input (see PasskeyService) // — the RP ID itself is never derived from request headers. @@ -81,6 +85,7 @@ export class PasskeyController { @HttpCode(200) @UseGuards(PasskeyEnabledGuard, JwtAuthGuard) async registerVerify(@CurrentUser() user: User, @Body() body: PasskeyRegisterVerifyDto, @Req() req: Request) { + refuseForDemoAccount(user, DEMO_ACCOUNT_REFUSAL.passkeys); const result = await this.passkeys.passkeyRegisterVerify(user.id, body); await this.audit.writeAudit({ userId: user.id, action: 'user.passkey_register', ip: getClientIp(req) }); return { success: true, credential: result.credential }; @@ -142,6 +147,7 @@ export class PasskeyController { @Patch('credentials/:id') @UseGuards(JwtAuthGuard) async rename(@CurrentUser() user: User, @Param('id') id: string, @Body() body: PasskeyRenameDto) { + refuseForDemoAccount(user, DEMO_ACCOUNT_REFUSAL.passkeys); await this.passkeys.renamePasskey(user.id, id, body?.name); return { success: true }; } @@ -154,6 +160,7 @@ export class PasskeyController { @Body() body: PasskeyDeleteDto, @Req() req: Request, ) { + refuseForDemoAccount(user, DEMO_ACCOUNT_REFUSAL.passkeys); await this.limit('login', req, 5); await this.passkeys.deletePasskey(user.id, id, body?.password); await this.audit.writeAudit({ diff --git a/server/src/nest/common/demo-write.ts b/server/src/nest/common/demo-write.ts index 663cf92e4..7d3855dc1 100644 --- a/server/src/nest/common/demo-write.ts +++ b/server/src/nest/common/demo-write.ts @@ -1,5 +1,7 @@ +import { readEnv } from '../../app-config'; import { RuntimeEnvService } from '../app-config/runtime-env.service'; -import { isDemoEmail } from './demo'; +import { DEMO_ACCOUNT_SETTINGS, isDemoEmail } from './demo'; +import { DomainError } from './domain-error'; /** The exact 403 body the six upload endpoints have always returned. */ export const DEMO_WRITE_ERROR = { @@ -29,3 +31,48 @@ export function isDemoWriteBlocked(env: RuntimeEnvService, email: string | null // `this.demo.isDemoUserId(userId)` instead: the same check, resolving `env`/ // the repository via its own constructor rather than taking them as // parameters. + +/** + * The shared account every visitor of a demo instance signs in as. The same + * reading of the environment as the password, MFA and session refusals in + * `auth/`, so a controller that has no RuntimeEnvService of its own (passkeys, + * OAuth) asks the same question without a new constructor parameter. + */ +export function isDemoAccount(user: { email?: string | null } | null | undefined): boolean { + return readEnv().demo.enabled && isDemoEmail(user?.email); +} + +/** + * What the demo account is told when it tries to change something every other + * visitor shares. One sentence per area, so the client can show it as it is. + */ +export const DEMO_ACCOUNT_REFUSAL = { + apiKeys: 'API keys cannot be created in demo mode.', + mcp: 'MCP clients cannot be connected in demo mode.', + account: 'The demo account cannot be changed in demo mode.', + passkeys: 'Passkeys cannot be changed in demo mode.', +} as const; + +/** Refuses the write with a 403 for the demo account; does nothing for anybody else. */ +export function refuseForDemoAccount(user: { email?: string | null } | null | undefined, message: string): void { + if (isDemoAccount(user)) throw new DomainError(403, message); +} + +/** + * Whether a settings write of this key is one the demo account does not get to + * make (`DEMO_ACCOUNT_SETTINGS`). The route answers as if it saved nothing, + * rather than refusing: a client that sends the language along with other keys + * keeps the rest of its save. + */ +export function isDemoLockedSetting(user: { email?: string | null } | null | undefined, key: string): boolean { + return Object.hasOwn(DEMO_ACCOUNT_SETTINGS, key) && isDemoAccount(user); +} + +/** The settings of a bulk save without the keys the demo account keeps fixed. */ +export function withoutDemoLockedSettings( + user: { email?: string | null } | null | undefined, + settings: Record, +): Record { + if (!isDemoAccount(user)) return settings; + return Object.fromEntries(Object.entries(settings).filter(([key]) => !Object.hasOwn(DEMO_ACCOUNT_SETTINGS, key))); +} diff --git a/server/src/nest/common/demo.ts b/server/src/nest/common/demo.ts index 5648f1651..eb5648cad 100644 --- a/server/src/nest/common/demo.ts +++ b/server/src/nest/common/demo.ts @@ -28,3 +28,12 @@ export const DEMO_EMAILS: ReadonlySet = new Set([DEMO_EMAIL_PRIMARY, 'de export function isDemoEmail(email: string | null | undefined): boolean { return !!email && DEMO_EMAILS.has(email); } + +/** + * User settings the shared demo account always holds, whatever a visitor picks. + * The demo seed writes them on every boot and after every hourly reset, and the + * settings routes leave them alone for the demo account (`demo-write.ts`), so a + * visitor cannot switch the demo every other visitor sees into another + * language. + */ +export const DEMO_ACCOUNT_SETTINGS: Readonly> = { language: 'en' }; diff --git a/server/src/nest/oauth/oauth-api.controller.ts b/server/src/nest/oauth/oauth-api.controller.ts index d965d1949..a9a5ef944 100644 --- a/server/src/nest/oauth/oauth-api.controller.ts +++ b/server/src/nest/oauth/oauth-api.controller.ts @@ -4,6 +4,7 @@ import { CookieAuthGuard } from '../auth-core/cookie-auth.guard'; import { CurrentUser } from '../auth-core/current-user.decorator'; import { JwtAuthGuard } from '../auth-core/jwt-auth.guard'; import { OptionalJwtGuard } from '../auth-core/optional-jwt.guard'; +import { DEMO_ACCOUNT_REFUSAL, refuseForDemoAccount } from '../common/demo-write'; import { RateLimitService } from '../common/rate-limit.service'; import { OauthClientCreateDto, OauthConsentDto } from './oauth.dto'; import { OauthService } from './oauth.service'; @@ -122,6 +123,8 @@ export class OauthApiController { if (body.state) url.searchParams.set('state', body.state); return { redirect: url.toString() }; } + // Saying no stays possible. Handing the shared demo account to an app does not. + refuseForDemoAccount(user, DEMO_ACCOUNT_REFUSAL.mcp); const scopes = validation.scopes!; await this.oauth.saveConsent(body.client_id, user.id, scopes, ip); const code = await this.oauth.createAuthCode({ @@ -157,6 +160,7 @@ export class OauthApiController { @UseGuards(CookieAuthGuard) async createClient(@CurrentUser() user: User, @Body() body: OauthClientCreateDto, @Req() req: Request) { await this.requireMcp403(); + refuseForDemoAccount(user, DEMO_ACCOUNT_REFUSAL.mcp); const result = await this.oauth.createOAuthClient( user.id, body.name, @@ -176,6 +180,7 @@ export class OauthApiController { @UseGuards(CookieAuthGuard) async rotateClient(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) { await this.requireMcp403(); + refuseForDemoAccount(user, DEMO_ACCOUNT_REFUSAL.mcp); const result = await this.oauth.rotateOAuthClientSecret(user.id, id, getClientIp(req)); if (result.error) { throw new HttpException({ error: result.error }, result.status || 400); @@ -187,6 +192,7 @@ export class OauthApiController { @UseGuards(CookieAuthGuard) async deleteClient(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) { await this.requireMcp403(); + refuseForDemoAccount(user, DEMO_ACCOUNT_REFUSAL.mcp); const result = await this.oauth.deleteOAuthClient(user.id, id, getClientIp(req)); if (result.error) { throw new HttpException({ error: result.error }, result.status || 400); @@ -205,6 +211,7 @@ export class OauthApiController { @UseGuards(CookieAuthGuard) async revokeSession(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) { await this.requireMcp403(); + refuseForDemoAccount(user, DEMO_ACCOUNT_REFUSAL.mcp); const result = await this.oauth.revokeSession(user.id, Number(id), getClientIp(req)); if (result.error) { throw new HttpException({ error: result.error }, result.status || 400); diff --git a/server/src/nest/settings/settings.controller.ts b/server/src/nest/settings/settings.controller.ts index 30373503b..88eecf6a5 100644 --- a/server/src/nest/settings/settings.controller.ts +++ b/server/src/nest/settings/settings.controller.ts @@ -5,6 +5,7 @@ import { getClientIp } from '../audit/client-ip'; import { AdminGuard } from '../auth-core/admin.guard'; import { CurrentUser } from '../auth-core/current-user.decorator'; import { JwtAuthGuard } from '../auth-core/jwt-auth.guard'; +import { isDemoLockedSetting, withoutDemoLockedSettings } from '../common/demo-write'; import { isManagedLockedKey, splitManagedKeys } from '../common/managed'; import { ResponseContract } from '../common/response-contract'; import { SettingsWritesService } from './settings-writes.service'; @@ -99,6 +100,10 @@ export class SettingsController { if (isManagedLockedKey(body.key) && this.env.isManaged()) { return { success: true, key: body.key, managed: true }; } + // The demo account keeps its language: every visitor sees the same demo. + if (isDemoLockedSetting(user, body.key)) { + return { success: true, key: body.key, unchanged: true }; + } // The client echoes a redacted secret back unchanged — treat as a no-op. if (body.value === MASKED_SETTING_VALUE) { return { success: true, key: body.key, unchanged: true }; @@ -113,7 +118,7 @@ export class SettingsController { async bulk(@CurrentUser() user: User, @Body() body: SettingsBulkDto, @Headers('x-socket-id') socketId?: string) { this.assertMayWriteInstanceEndpoint(body.settings); this.assertValidValues(body.settings); - const { allowed, blocked } = splitManagedKeys(body.settings, this.env.isManaged()); + const { allowed, blocked } = splitManagedKeys(withoutDemoLockedSettings(user, body.settings), this.env.isManaged()); try { const updated = await this.writes.saveMany(user.id, allowed, socketId); return { success: true, updated, ...(blocked.length ? { managed_keys: blocked } : {}) }; diff --git a/server/tests/e2e/demo-account-locks.e2e.test.ts b/server/tests/e2e/demo-account-locks.e2e.test.ts new file mode 100644 index 000000000..246634345 --- /dev/null +++ b/server/tests/e2e/demo-account-locks.e2e.test.ts @@ -0,0 +1,198 @@ +/** + * Demo account locks e2e: on a demo instance the shared demo account cannot + * mint API keys or MCP tokens, change its name, address or picture, or touch + * passkeys, and its language stays English. Every visitor signs in as that one + * account, so each of these would be one stranger's change to everybody's demo. + * + * Boots AuthModule (with the passkey routes) and SettingsModule with the real + * JwtAuthGuard, the Zod pipe and the exception filter over a migrated temp + * SQLite db. The other accounts on the same instance, and the demo address on + * an instance that is not a demo, keep every one of these routes. + */ +import { Settings } from '../../src/db/entities/Settings.entity'; +import { AppConfigModule } from '../../src/nest/app-config/app-config.module'; +import { AuthModule } from '../../src/nest/auth/auth.module'; +import { DEMO_ACCOUNT_REFUSAL } from '../../src/nest/common/demo-write'; +import { DemoModule } from '../../src/nest/common/demo.module'; +import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; +import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe'; +import { MailerService } from '../../src/nest/notifications/mailer/mailer.service'; +import { RealtimeModule } from '../../src/nest/realtime/realtime.module'; +import { RealtimeService } from '../../src/nest/realtime/realtime.service'; +import { SettingsModule } from '../../src/nest/settings/settings.module'; +import { createSnapshotTestDb } from '../helpers/db-mock'; +import { deleteRows, findRow, insertRow } from '../helpers/factories/rows'; +import { makeUser } from '../helpers/factories/users'; +import { FakeRealtimeService } from '../helpers/fake-realtime'; +import { resetRateLimits } from '../helpers/test-db'; +import { createTestMikroOrmModule, createTestOrm, type TestOrm } from '../helpers/test-orm'; +import { TestUnitOfWorkModule } from '../helpers/test-uow'; +import { sessionCookie } from './harness'; +import { Test } from '@nestjs/testing'; + +import cookieParser from 'cookie-parser'; +import type { Server } from 'http'; +import request from 'supertest'; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../src/nest/audit/audit-log.logger', () => ({ + LOG_LEVEL: 'error', + logInfo: vi.fn(), + logDebug: vi.fn(), + logError: vi.fn(), + logWarn: vi.fn(), +})); + +const db = createSnapshotTestDb(); +const DEMO = 1; +const VISITOR_ADMIN = 2; + +let orm: TestOrm; +let server: Server; +let app: Awaited>; + +async function build() { + const moduleRef = await Test.createTestingModule({ + imports: [ + await TestUnitOfWorkModule.forRoot(db), + await createTestMikroOrmModule(db), + AppConfigModule, + RealtimeModule, + DemoModule, + AuthModule, + SettingsModule, + ], + }) + .overrideProvider(RealtimeService) + .useValue(new FakeRealtimeService()) + .overrideProvider(MailerService) + .useValue({ sendPasswordResetEmail: vi.fn() }) + .compile(); + const nest = moduleRef.createNestApplication(); + nest.use(cookieParser()); + nest.useGlobalPipes(new ZodValidationPipe()); + nest.useGlobalFilters(new TrekExceptionFilter()); + await nest.init(); + return nest; +} + +const as = (userId: number) => ({ Cookie: sessionCookie(userId) }); + +async function languageOf(userId: number): Promise { + return (await findRow(orm, Settings, { user: userId, key: 'language' }))?.value; +} + +beforeAll(async () => { + orm = await createTestOrm(db); + await makeUser(orm, { id: DEMO, username: 'demo', email: 'demo@trek.app' }); + await makeUser(orm, { id: VISITOR_ADMIN, username: 'admin', email: 'admin@example.test', role: 'admin' }); + app = await build(); + server = app.getHttpServer(); +}); + +beforeEach(async () => { + vi.stubEnv('DEMO_MODE', 'true'); + await resetRateLimits(app); + await deleteRows(orm, Settings); + await insertRow(orm, Settings, { user: DEMO, key: 'language', value: 'en' }); +}); + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +afterAll(async () => { + await app.close(); + await orm.close(); +}); + +describe('Demo account locks e2e', () => { + it('DEMOLOCK-001: the demo account cannot create an API key', async () => { + const res = await request(server).post('/api/auth/api-tokens').set(as(DEMO)).send({ name: 'Mine' }); + expect(res.status).toBe(403); + expect(res.body).toEqual({ error: DEMO_ACCOUNT_REFUSAL.apiKeys }); + }); + + it('DEMOLOCK-002: the demo account can neither create nor delete an MCP token', async () => { + const created = await request(server).post('/api/auth/mcp-tokens').set(as(DEMO)).send({ name: 'Claude' }); + expect(created.status).toBe(403); + expect(created.body).toEqual({ error: DEMO_ACCOUNT_REFUSAL.mcp }); + + const removed = await request(server).delete('/api/auth/mcp-tokens/1').set(as(DEMO)); + expect(removed.status).toBe(403); + expect(removed.body).toEqual({ error: DEMO_ACCOUNT_REFUSAL.mcp }); + }); + + it('DEMOLOCK-003: the name, the address and the picture of the demo account stay as seeded', async () => { + const name = await request(server).put('/api/auth/me/settings').set(as(DEMO)).send({ username: 'renamed' }); + expect(name.status).toBe(403); + expect(name.body).toEqual({ error: DEMO_ACCOUNT_REFUSAL.account }); + + const address = await request(server) + .put('/api/auth/me/settings') + .set(as(DEMO)) + .send({ email: 'mine@example.test' }); + expect(address.status).toBe(403); + + const avatar = await request(server).delete('/api/auth/avatar').set(as(DEMO)); + expect(avatar.status).toBe(403); + expect(avatar.body).toEqual({ error: DEMO_ACCOUNT_REFUSAL.account }); + }); + + it('DEMOLOCK-004: passkeys of the demo account can be neither renamed nor deleted', async () => { + const renamed = await request(server) + .patch('/api/auth/passkey/credentials/abc') + .set(as(DEMO)) + .send({ name: 'Laptop' }); + expect(renamed.status).toBe(403); + expect(renamed.body).toEqual({ error: DEMO_ACCOUNT_REFUSAL.passkeys }); + + const deleted = await request(server) + .delete('/api/auth/passkey/credentials/abc') + .set(as(DEMO)) + .send({ password: 'demo12345' }); + expect(deleted.status).toBe(403); + expect(deleted.body).toEqual({ error: DEMO_ACCOUNT_REFUSAL.passkeys }); + }); + + it('DEMOLOCK-005: a language picked on the demo account is answered as unchanged and not stored', async () => { + const one = await request(server).put('/api/settings').set(as(DEMO)).send({ key: 'language', value: 'de' }); + expect(one.status).toBe(200); + expect(one.body).toEqual({ success: true, key: 'language', unchanged: true }); + expect(await languageOf(DEMO)).toBe('en'); + }); + + it('DEMOLOCK-006: a bulk save of the demo account keeps every other key and drops the language', async () => { + const res = await request(server) + .post('/api/settings/bulk') + .set(as(DEMO)) + .send({ settings: { language: 'fr', temperature_unit: 'fahrenheit' } }); + expect(res.status).toBe(200); + expect(res.body).toEqual({ success: true, updated: 1 }); + expect(await languageOf(DEMO)).toBe('en'); + + const settings = await request(server).get('/api/settings').set(as(DEMO)); + expect(settings.body.settings).toEqual(expect.objectContaining({ language: 'en', temperature_unit: 'fahrenheit' })); + }); + + it('DEMOLOCK-007: the admin of a demo instance keeps every one of these routes', async () => { + const token = await request(server).post('/api/auth/api-tokens').set(as(VISITOR_ADMIN)).send({ name: 'Admin' }); + expect(token.status).toBe(201); + + const language = await request(server) + .put('/api/settings') + .set(as(VISITOR_ADMIN)) + .send({ key: 'language', value: 'de' }); + expect(language.status).toBe(200); + expect(await languageOf(VISITOR_ADMIN)).toBe('de'); + }); + + it('DEMOLOCK-008: off a demo instance the demo address is an account like any other', async () => { + vi.stubEnv('DEMO_MODE', 'false'); + const token = await request(server).post('/api/auth/api-tokens').set(as(DEMO)).send({ name: 'Mine' }); + expect(token.status).toBe(201); + + await request(server).put('/api/settings').set(as(DEMO)).send({ key: 'language', value: 'de' }); + expect(await languageOf(DEMO)).toBe('de'); + }); +}); diff --git a/server/tests/unit/nest/demo-write.test.ts b/server/tests/unit/nest/demo-write.test.ts index 9a7409458..fe89019c3 100644 --- a/server/tests/unit/nest/demo-write.test.ts +++ b/server/tests/unit/nest/demo-write.test.ts @@ -12,11 +12,23 @@ */ import { RuntimeEnvService } from '../../../src/nest/app-config/runtime-env.service'; import { isDemoEmail } from '../../../src/nest/common/demo'; -import { isDemoWriteBlocked, DEMO_WRITE_ERROR } from '../../../src/nest/common/demo-write'; +import { + DEMO_ACCOUNT_REFUSAL, + DEMO_WRITE_ERROR, + isDemoAccount, + isDemoLockedSetting, + isDemoWriteBlocked, + refuseForDemoAccount, + withoutDemoLockedSettings, +} from '../../../src/nest/common/demo-write'; +import { DomainError } from '../../../src/nest/common/domain-error'; import { describe, it, expect, vi, afterEach } from 'vitest'; -vi.mock('../../../src/nest/common/demo', () => ({ isDemoEmail: vi.fn(() => false) })); +vi.mock('../../../src/nest/common/demo', async (importOriginal) => ({ + ...(await importOriginal()), + isDemoEmail: vi.fn(() => false), +})); const env = new RuntimeEnvService(); @@ -60,3 +72,50 @@ describe('isDemoWriteBlocked', () => { }); }); }); + +describe('the demo account locks', () => { + const demo = { email: 'demo@trek.app' }; + const other = { email: 'alice@example.com' }; + const onDemoInstance = () => { + process.env.DEMO_MODE = 'true'; + vi.mocked(isDemoEmail).mockImplementation((email) => email === 'demo@trek.app'); + }; + + it('DEMO-WRITE-006: isDemoAccount is the demo address on a demo instance, and nothing else', () => { + vi.mocked(isDemoEmail).mockImplementation((email) => email === 'demo@trek.app'); + expect(isDemoAccount(demo)).toBe(false); + onDemoInstance(); + expect(isDemoAccount(demo)).toBe(true); + expect(isDemoAccount(other)).toBe(false); + expect(isDemoAccount(null)).toBe(false); + }); + + it('DEMO-WRITE-007: refuseForDemoAccount throws a 403 with the message for the demo account only', () => { + onDemoInstance(); + expect(() => refuseForDemoAccount(other, DEMO_ACCOUNT_REFUSAL.apiKeys)).not.toThrow(); + let thrown: unknown; + try { + refuseForDemoAccount(demo, DEMO_ACCOUNT_REFUSAL.apiKeys); + } catch (err) { + thrown = err; + } + expect(thrown).toBeInstanceOf(DomainError); + expect((thrown as DomainError).getStatus()).toBe(403); + expect((thrown as DomainError).getResponse()).toEqual({ error: 'API keys cannot be created in demo mode.' }); + }); + + it('DEMO-WRITE-008: the language is the setting the demo account keeps', () => { + onDemoInstance(); + expect(isDemoLockedSetting(demo, 'language')).toBe(true); + expect(isDemoLockedSetting(demo, 'temperature_unit')).toBe(false); + expect(isDemoLockedSetting(other, 'language')).toBe(false); + }); + + it('DEMO-WRITE-009: a bulk save of the demo account loses the language and keeps the rest', () => { + const settings = { language: 'de', distance_unit: 'imperial' }; + expect(withoutDemoLockedSettings(demo, settings)).toBe(settings); + onDemoInstance(); + expect(withoutDemoLockedSettings(demo, settings)).toEqual({ distance_unit: 'imperial' }); + expect(withoutDemoLockedSettings(other, settings)).toBe(settings); + }); +}); diff --git a/server/tests/unit/nest/oauth-demo-lock.test.ts b/server/tests/unit/nest/oauth-demo-lock.test.ts new file mode 100644 index 000000000..56685b70b --- /dev/null +++ b/server/tests/unit/nest/oauth-demo-lock.test.ts @@ -0,0 +1,110 @@ +/** + * The OAuth half of the MCP lock on a demo instance: the shared demo account + * cannot register, rotate or delete an OAuth client, end a connected app, or + * approve an app's request for access. Turning a request down stays possible. + * Every refusal comes before the service is asked, so nothing is written. + */ +import { DEMO_ACCOUNT_REFUSAL } from '../../../src/nest/common/demo-write'; +import { RateLimitService } from '../../../src/nest/common/rate-limit.service'; +import { OauthApiController } from '../../../src/nest/oauth/oauth-api.controller'; +import type { OauthService } from '../../../src/nest/oauth/oauth.service'; +import type { User } from '../../../src/types'; +import { HttpException } from '@nestjs/common'; + +import type { Request } from 'express'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../../src/nest/audit/client-ip', () => ({ getClientIp: vi.fn(() => '1.2.3.4') })); + +const demo = { id: 1, role: 'user', email: 'demo@trek.app' } as User; +const other = { id: 2, role: 'user', email: 'alice@example.test' } as User; +const req = { ip: '1.2.3.4', headers: {} } as unknown as Request; + +function service() { + return { + mcpEnabled: vi.fn().mockResolvedValue(true), + validateAuthorizeRequest: vi.fn().mockResolvedValue({ valid: true, scopes: ['trips:read'], resource: null }), + saveConsent: vi.fn(), + createAuthCode: vi.fn().mockResolvedValue('the_code'), + createOAuthClient: vi.fn().mockResolvedValue({ client_id: 'c1', client_secret: 's' }), + rotateOAuthClientSecret: vi.fn().mockResolvedValue({ client_secret: 's2' }), + deleteOAuthClient: vi.fn().mockResolvedValue({}), + revokeSession: vi.fn().mockResolvedValue({}), + }; +} + +let svc: ReturnType; +const controller = () => new OauthApiController(svc as unknown as OauthService, new RateLimitService()); + +async function refusal(fn: () => Promise): Promise<{ status: number; body: unknown }> { + try { + await fn(); + } catch (err) { + expect(err).toBeInstanceOf(HttpException); + const e = err as HttpException; + return { status: e.getStatus(), body: e.getResponse() }; + } + throw new Error('expected a refusal'); +} + +const consent = (approved: boolean) => ({ + client_id: 'c1', + redirect_uri: 'https://app.example.test/cb', + scope: 'trips:read', + state: 'st', + code_challenge: 'cc', + code_challenge_method: 'S256' as const, + approved, +}); + +const MCP_REFUSAL = { status: 403, body: { error: DEMO_ACCOUNT_REFUSAL.mcp } }; + +beforeEach(() => { + vi.stubEnv('DEMO_MODE', 'true'); + svc = service(); +}); + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe('OAuth routes for the demo account', () => { + it('OAUTH-DEMO-001: registering, rotating and deleting a client are refused before the service runs', async () => { + expect(await refusal(() => controller().createClient(demo, { name: 'CLI', allowed_scopes: ['a'] }, req))).toEqual( + MCP_REFUSAL, + ); + expect(await refusal(() => controller().rotateClient(demo, 'c1', req))).toEqual(MCP_REFUSAL); + expect(await refusal(() => controller().deleteClient(demo, 'c1', req))).toEqual(MCP_REFUSAL); + expect(svc.createOAuthClient).not.toHaveBeenCalled(); + expect(svc.rotateOAuthClientSecret).not.toHaveBeenCalled(); + expect(svc.deleteOAuthClient).not.toHaveBeenCalled(); + }); + + it('OAUTH-DEMO-002: a connected app cannot be ended from the demo account', async () => { + expect(await refusal(() => controller().revokeSession(demo, '7', req))).toEqual(MCP_REFUSAL); + expect(svc.revokeSession).not.toHaveBeenCalled(); + }); + + it('OAUTH-DEMO-003: approving an app is refused, turning it down still redirects', async () => { + expect(await refusal(() => controller().authorize(demo, consent(true), req))).toEqual(MCP_REFUSAL); + expect(svc.saveConsent).not.toHaveBeenCalled(); + expect(svc.createAuthCode).not.toHaveBeenCalled(); + + const denied = await controller().authorize(demo, consent(false), req); + expect((denied as { redirect: string }).redirect).toContain('error=access_denied'); + }); + + it('OAUTH-DEMO-004: every other account of the demo instance keeps the routes', async () => { + expect(await controller().createClient(other, { name: 'CLI', allowed_scopes: ['a'] }, req)).toEqual({ + client_id: 'c1', + client_secret: 's', + }); + const approved = await controller().authorize(other, consent(true), req); + expect((approved as { redirect: string }).redirect).toContain('code=the_code'); + }); + + it('OAUTH-DEMO-005: off a demo instance the demo address keeps them too', async () => { + vi.stubEnv('DEMO_MODE', 'false'); + expect(await controller().deleteClient(demo, 'c1', req)).toEqual({ success: true }); + }); +});