mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-13 15:51:00 +00:00
130 lines
5.1 KiB
TypeScript
130 lines
5.1 KiB
TypeScript
/**
|
|
* 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.
|
|
*
|
|
* The controller comes out of OauthModule with OauthService replaced by the
|
|
* stand-in below, so it is wired the way the app wires it.
|
|
*/
|
|
import { DEMO_ACCOUNT_REFUSAL } from '../../../src/nest/common/demo-write';
|
|
import { OauthApiController } from '../../../src/nest/oauth/oauth-api.controller';
|
|
import { OauthModule } from '../../../src/nest/oauth/oauth.module';
|
|
import { OauthService } from '../../../src/nest/oauth/oauth.service';
|
|
import type { User } from '../../../src/types';
|
|
import { createSnapshotTestDb } from '../../helpers/db-mock';
|
|
import { createTestModule, type TestModule } from '../../helpers/test-module';
|
|
import { HttpException } from '@nestjs/common';
|
|
|
|
import type { Request } from 'express';
|
|
import { afterAll, afterEach, beforeAll, 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({}),
|
|
};
|
|
}
|
|
|
|
const db = createSnapshotTestDb();
|
|
// The module hands the controller this very object, so each test re-arms its
|
|
// mocks in place instead of building a new container.
|
|
const svc = service();
|
|
let t: TestModule;
|
|
let api: OauthApiController;
|
|
|
|
beforeAll(async () => {
|
|
t = await createTestModule({ db, imports: [OauthModule], overrides: [{ provide: OauthService, useValue: svc }] });
|
|
api = t.get(OauthApiController);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await t.close();
|
|
db.close();
|
|
});
|
|
|
|
async function refusal(fn: () => Promise<unknown>): 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');
|
|
Object.assign(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(() => api.createClient(demo, { name: 'CLI', allowed_scopes: ['a'] }, req))).toEqual(
|
|
MCP_REFUSAL,
|
|
);
|
|
expect(await refusal(() => api.rotateClient(demo, 'c1', req))).toEqual(MCP_REFUSAL);
|
|
expect(await refusal(() => api.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(() => api.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(() => api.authorize(demo, consent(true), req))).toEqual(MCP_REFUSAL);
|
|
expect(svc.saveConsent).not.toHaveBeenCalled();
|
|
expect(svc.createAuthCode).not.toHaveBeenCalled();
|
|
|
|
const denied = await api.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 api.createClient(other, { name: 'CLI', allowed_scopes: ['a'] }, req)).toEqual({
|
|
client_id: 'c1',
|
|
client_secret: 's',
|
|
});
|
|
const approved = await api.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 api.deleteClient(demo, 'c1', req)).toEqual({ success: true });
|
|
});
|
|
});
|