mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-22 23:16:46 +00:00
214984f398
Adds an in-app assistant that reads and plans the open trip. Users connect their own provider (Sign in with ChatGPT, or an Anthropic, OpenAI, Gemini or OpenRouter key) or use the one the admin set up, including a custom OpenAI-compatible server. It is an addon and only shows while a connection is usable. The chat runs on the existing MCP tools, pinned to the trip. Every change is held as a change card the user applies or discards, and applied changes can be undone. On the desktop it is a floating window that can be moved and resized; on the phone it is fullscreen. Search results show as place cards that can go onto a day, weather lookups as a strip, proposed places as ghost markers on the map with a before/after switch, and an answer can be shared into the trip chat. Booking files can be dropped in and are read into change cards.
194 lines
7.8 KiB
TypeScript
194 lines
7.8 KiB
TypeScript
/**
|
|
* OAuth e2e — exercises the migrated /oauth/* and /api/oauth/* endpoints through
|
|
* the real JwtAuthGuard / CookieAuthGuard / OptionalJwtGuard against a temp
|
|
* SQLite db. The OAuth service + addon gate are mocked; this focuses on the
|
|
* public token/userinfo guards, the MCP 404/403 gates, and the cookie-only auth
|
|
* on the management endpoints (a Bearer must NOT satisfy them).
|
|
*/
|
|
import { AddonsService } from '../../src/nest/addons/addons.service';
|
|
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
|
|
import { OauthModule } from '../../src/nest/oauth/oauth.module';
|
|
import { OauthService } from '../../src/nest/oauth/oauth.service';
|
|
import { createTestMikroOrmModule } from '../helpers/test-orm';
|
|
import { TestUnitOfWorkModule } from '../helpers/test-uow';
|
|
import { seedUser, sessionCookie, signSession } from './harness';
|
|
import { Test } from '@nestjs/testing';
|
|
|
|
import cookieParser from 'cookie-parser';
|
|
import type { Server } from 'http';
|
|
import request from 'supertest';
|
|
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
|
|
|
|
const { db } = vi.hoisted(() => {
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
const Database = require('better-sqlite3');
|
|
const tmp = new Database(':memory:');
|
|
tmp.exec('PRAGMA journal_mode = WAL');
|
|
tmp.exec(`CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL,
|
|
email TEXT NOT NULL UNIQUE, role TEXT NOT NULL DEFAULT 'user', password_version INTEGER NOT NULL DEFAULT 0);`);
|
|
// AuditService now runs its real INSERT (DI-injected, no mock) — slim
|
|
// audit_log mirror (no FKs), same shape as plugin-runtime.test.ts.
|
|
tmp.exec(`CREATE TABLE audit_log (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
user_id INTEGER, action TEXT NOT NULL, resource TEXT, details TEXT, ip TEXT);`);
|
|
return { db: tmp };
|
|
});
|
|
|
|
vi.mock('../../src/db/database', () => ({ db, closeDb: () => {}, reinitialize: () => {} }));
|
|
// The audit domain is DI-native now: writeAudit runs for real against the temp
|
|
// db's audit_log table; only the file logger is silenced.
|
|
vi.mock('../../src/nest/audit/audit-log.logger', () => ({
|
|
LOG_LEVEL: 'error',
|
|
logInfo: vi.fn(),
|
|
logDebug: vi.fn(),
|
|
logError: vi.fn(),
|
|
logWarn: vi.fn(),
|
|
}));
|
|
vi.mock('../../src/app-config', async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import('../../src/app-config')>();
|
|
return { ...actual, getMcpSafeUrl: () => 'https://app' };
|
|
});
|
|
|
|
const { isAddonEnabled } = vi.hoisted(() => ({ isAddonEnabled: vi.fn(() => true) }));
|
|
|
|
// Stubbed at the provider, not the module path: the domain folded into
|
|
// OauthService, so there is no legacy module left to vi.mock. Same six cases,
|
|
// same assertions — these pin the guard/gate layer, not the OAuth logic.
|
|
const { oauthSvc } = vi.hoisted(() => ({
|
|
oauthSvc: {
|
|
validateAuthorizeRequest: vi.fn(),
|
|
createAuthCode: vi.fn(),
|
|
consumeAuthCode: vi.fn(),
|
|
saveConsent: vi.fn(),
|
|
issueTokens: vi.fn(),
|
|
issueClientCredentialsToken: vi.fn(),
|
|
refreshTokens: vi.fn(),
|
|
revokeToken: vi.fn(),
|
|
verifyPKCE: vi.fn(),
|
|
authenticateClient: vi.fn(),
|
|
listOAuthClients: vi.fn(),
|
|
createOAuthClient: vi.fn(),
|
|
deleteOAuthClient: vi.fn(),
|
|
rotateOAuthClientSecret: vi.fn(),
|
|
listOAuthSessions: vi.fn(),
|
|
revokeSession: vi.fn(),
|
|
getUserByAccessToken: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
describe('OAuth e2e (real guards + temp SQLite)', () => {
|
|
let server: Server;
|
|
let app: Awaited<ReturnType<typeof build>>;
|
|
|
|
async function build() {
|
|
const moduleRef = await Test.createTestingModule({
|
|
imports: [await TestUnitOfWorkModule.forRoot(db), await createTestMikroOrmModule(db), OauthModule],
|
|
})
|
|
.overrideProvider(AddonsService)
|
|
.useValue({ isAddonEnabled })
|
|
.overrideProvider(OauthService)
|
|
.useValue({ ...oauthSvc, mcpEnabled: () => isAddonEnabled(), mcpSafeUrl: () => 'https://mcp.test' })
|
|
.compile();
|
|
const nest = moduleRef.createNestApplication();
|
|
nest.use(cookieParser());
|
|
nest.useGlobalFilters(new TrekExceptionFilter());
|
|
await nest.init();
|
|
return nest;
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
seedUser(db as never, { id: 1 });
|
|
app = await build();
|
|
server = app.getHttpServer();
|
|
oauthSvc.listOAuthClients.mockReturnValue([{ id: 'c1' }]);
|
|
});
|
|
|
|
beforeEach(() => {
|
|
isAddonEnabled.mockReturnValue(true);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await app.close();
|
|
});
|
|
|
|
it('POST /oauth/token is public — 401 invalid_client without client_id', async () => {
|
|
const res = await request(server).post('/oauth/token').send({});
|
|
expect(res.status).toBe(401);
|
|
expect(res.body).toEqual({ error: 'invalid_client', error_description: 'client_id is required' });
|
|
expect(res.headers['cache-control']).toBe('no-store');
|
|
});
|
|
|
|
it('POST /oauth/token 404 (empty) when MCP is disabled', async () => {
|
|
isAddonEnabled.mockReturnValue(false);
|
|
const res = await request(server).post('/oauth/token').send({ client_id: 'c' });
|
|
expect(res.status).toBe(404);
|
|
expect(res.text).toBe('');
|
|
});
|
|
|
|
it('GET /oauth/userinfo 401 with a WWW-Authenticate challenge', async () => {
|
|
const res = await request(server).get('/oauth/userinfo');
|
|
expect(res.status).toBe(401);
|
|
expect(res.headers['www-authenticate']).toContain('Bearer');
|
|
});
|
|
|
|
it('GET /api/oauth/clients 401 without a session', async () => {
|
|
expect((await request(server).get('/api/oauth/clients')).status).toBe(401);
|
|
});
|
|
|
|
it('GET /api/oauth/clients works with a Bearer (authenticate) session', async () => {
|
|
const res = await request(server)
|
|
.get('/api/oauth/clients')
|
|
.set('Authorization', `Bearer ${signSession(1)}`);
|
|
expect(res.status).toBe(200);
|
|
expect(res.body).toEqual({ clients: [{ id: 'c1' }] });
|
|
});
|
|
|
|
it('POST /api/oauth/clients requires a COOKIE session (a Bearer is rejected)', async () => {
|
|
const bearer = await request(server)
|
|
.post('/api/oauth/clients')
|
|
.set('Authorization', `Bearer ${signSession(1)}`)
|
|
.send({ name: 'CLI', allowed_scopes: ['a'] });
|
|
expect(bearer.status).toBe(401);
|
|
expect(bearer.body).toEqual({ error: 'Cookie session required for this endpoint', code: 'COOKIE_AUTH_REQUIRED' });
|
|
|
|
oauthSvc.createOAuthClient.mockReturnValue({ client_id: 'c1', client_secret: 's' });
|
|
const cookie = await request(server)
|
|
.post('/api/oauth/clients')
|
|
.set('Cookie', sessionCookie(1))
|
|
.send({ name: 'CLI', allowed_scopes: ['a'] });
|
|
expect(cookie.status).toBe(201);
|
|
expect(cookie.body).toEqual({ client_id: 'c1', client_secret: 's' });
|
|
});
|
|
|
|
describe('GET /api/integrations/claude-connector', () => {
|
|
const MCP_URL = 'https://mcp.test/mcp';
|
|
|
|
it('404 { error } while the MCP addon is off, even without a session', async () => {
|
|
isAddonEnabled.mockReturnValue(false);
|
|
const res = await request(server).get('/api/integrations/claude-connector');
|
|
expect(res.status).toBe(404);
|
|
expect(res.body).toEqual({ error: 'MCP addon is not enabled' });
|
|
});
|
|
|
|
it('401 without a session', async () => {
|
|
const res = await request(server).get('/api/integrations/claude-connector');
|
|
expect(res.status).toBe(401);
|
|
});
|
|
|
|
it('200 with the MCP endpoint, the claude.ai link that adds it and that link as a QR code', async () => {
|
|
const res = await request(server).get('/api/integrations/claude-connector').set('Cookie', sessionCookie(1));
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.mcpUrl).toBe(MCP_URL);
|
|
const link = new URL(res.body.deepLink);
|
|
expect(link.origin + link.pathname).toBe('https://claude.ai/customize/connectors');
|
|
expect(Object.fromEntries(link.searchParams)).toEqual({
|
|
modal: 'add-custom-connector',
|
|
connectorName: 'TREK',
|
|
connectorUrl: MCP_URL,
|
|
});
|
|
expect(res.body.qrSvg).toMatch(/^<svg[\s\S]*<\/svg>\s*$/);
|
|
});
|
|
});
|
|
});
|