From eae74c56056cb989ae8e19722edbd9d5ccca8cb2 Mon Sep 17 00:00:00 2001 From: Maurice Date: Sat, 24 Oct 2026 23:33:48 +0200 Subject: [PATCH] feat(demo): the shared demo account keeps its keys, account and language Every visitor of a demo instance signs in as the same account, so one visitor's API key, MCP token, OAuth client, passkey or new email address became everybody's. These writes now answer 403 for the demo account through refuseForDemoAccount in demo-write.ts: creating API keys, creating and deleting MCP tokens, registering, rotating and deleting OAuth clients, approving an app and ending a connected app, changing the name, address or picture, and enrolling, renaming or deleting passkeys. Password, 2FA, account deletion, sessions and the own AI connection were refused already. The demo account's language is fixed to English: a single settings write of the language answers unchanged, a bulk save drops it and keeps the rest. The MCP settings tool is held back for the demo account by the tool gate, as every write tool is. The admin of a demo instance and every real instance are untouched. --- server/src/nest/auth/auth.controller.ts | 11 +- server/src/nest/auth/passkey.controller.ts | 7 + server/src/nest/common/demo-write.ts | 49 ++++- server/src/nest/common/demo.ts | 9 + server/src/nest/oauth/oauth-api.controller.ts | 7 + .../src/nest/settings/settings.controller.ts | 7 +- .../tests/e2e/demo-account-locks.e2e.test.ts | 198 ++++++++++++++++++ server/tests/unit/nest/demo-write.test.ts | 63 +++++- .../tests/unit/nest/oauth-demo-lock.test.ts | 110 ++++++++++ 9 files changed, 456 insertions(+), 5 deletions(-) create mode 100644 server/tests/e2e/demo-account-locks.e2e.test.ts create mode 100644 server/tests/unit/nest/oauth-demo-lock.test.ts 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 }); + }); +});