ci/woodpecker/pr/ci Pipeline failed
Add the Slice-Zero catalog and selection HTTP surfaces for P3 Task 3: GET /api/harnesses, GET /api/harnesses/:harnessId/catalog, GET+PUT /api/chat/preferences/selection. Scope is always server-derived via scopeFromUser(CurrentUser); selection tuples are validated against the live catalog with no fallback substitution and persisted in a transitional owner-scoped in-memory store. HarnessModule is wired into AppModule. Co-Authored-By: Claude Opus 4.8 <[email protected]> Claude-Session: https://claude.ai/code/session_01ESFAnh2t9HmLwng8oW95St
139 lines
4.7 KiB
TypeScript
139 lines
4.7 KiB
TypeScript
import 'reflect-metadata';
|
|
import {
|
|
type CanActivate,
|
|
type ExecutionContext,
|
|
type INestApplication,
|
|
ValidationPipe,
|
|
} from '@nestjs/common';
|
|
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
|
|
import { Test } from '@nestjs/testing';
|
|
import request from 'supertest';
|
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
import { AuthGuard } from '../auth/auth.guard.js';
|
|
import { HarnessRegistry } from './harness.registry.js';
|
|
import { HARNESS_REGISTRY } from './harness.tokens.js';
|
|
import { FakeHarnessAdapter } from './testing/fake-harness.adapter.js';
|
|
// The real module under test — importing it (not a hand-listed controllers/mocks
|
|
// list) is what makes an unresolved provider fail loudly at app.init() (#1145 guard).
|
|
import { HarnessModule } from './harness.module.js';
|
|
|
|
// Fields that must NEVER surface on a browser-facing catalog/list response.
|
|
const FORBIDDEN_KEYS = [
|
|
'executable',
|
|
'executablePath',
|
|
'home',
|
|
'homeDir',
|
|
'cwd',
|
|
'workingDir',
|
|
'workingDirectory',
|
|
'nativeSessionPath',
|
|
'sessionPath',
|
|
'env',
|
|
'secret',
|
|
'secrets',
|
|
'token',
|
|
'apiKey',
|
|
];
|
|
|
|
function assertNoForbiddenLeak(payload: unknown): void {
|
|
const serialized = JSON.stringify(payload).toLowerCase();
|
|
for (const key of FORBIDDEN_KEYS) {
|
|
expect(serialized).not.toContain(key.toLowerCase());
|
|
}
|
|
}
|
|
|
|
const authGuard: CanActivate = {
|
|
canActivate(context: ExecutionContext): boolean {
|
|
const requestContext = context.switchToHttp().getRequest<{ user?: { id: string } }>();
|
|
requestContext.user = { id: 'user-1' };
|
|
return true;
|
|
},
|
|
};
|
|
|
|
function registryWithFake(): HarnessRegistry {
|
|
const registry = new HarnessRegistry();
|
|
registry.register(new FakeHarnessAdapter({ id: 'fake' }));
|
|
return registry;
|
|
}
|
|
|
|
describe('Harness catalog HTTP surface', () => {
|
|
let app: INestApplication;
|
|
|
|
beforeAll(async () => {
|
|
const moduleRef = await Test.createTestingModule({
|
|
imports: [HarnessModule],
|
|
})
|
|
.overrideGuard(AuthGuard)
|
|
.useValue(authGuard)
|
|
.overrideProvider(HARNESS_REGISTRY)
|
|
.useValue(registryWithFake())
|
|
.compile();
|
|
|
|
app = moduleRef.createNestApplication<NestFastifyApplication>(new FastifyAdapter());
|
|
app.useGlobalPipes(
|
|
new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true }),
|
|
);
|
|
await app.init();
|
|
await app.getHttpAdapter().getInstance().ready();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await app.close();
|
|
});
|
|
|
|
it('boots the real HarnessModule so all providers resolve at app.init()', () => {
|
|
// If HarnessModule failed to resolve a provider, beforeAll's app.init() would
|
|
// have thrown and this suite would never reach here.
|
|
expect(app).toBeDefined();
|
|
});
|
|
|
|
it('GET /api/harnesses returns 200 with safe fields only', async () => {
|
|
const response = await request(app.getHttpServer()).get('/api/harnesses');
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(Array.isArray(response.body)).toBe(true);
|
|
expect(response.body.length).toBeGreaterThan(0);
|
|
const summary = response.body[0];
|
|
expect(Object.keys(summary).sort()).toEqual(['capabilities', 'displayName', 'id']);
|
|
expect(summary.id).toBe('fake');
|
|
expect(typeof summary.displayName).toBe('string');
|
|
expect(Array.isArray(summary.capabilities)).toBe(true);
|
|
assertNoForbiddenLeak(response.body);
|
|
});
|
|
|
|
it('GET /api/harnesses/:harnessId/catalog returns 200 with safe catalog fields only', async () => {
|
|
const response = await request(app.getHttpServer()).get('/api/harnesses/fake/catalog');
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.body.harnessId).toBe('fake');
|
|
expect(typeof response.body.version).toBe('string');
|
|
expect(typeof response.body.fingerprint).toBe('string');
|
|
expect(Array.isArray(response.body.models)).toBe(true);
|
|
expect(response.body.models.length).toBeGreaterThan(0);
|
|
const entry = response.body.models[0];
|
|
// Whitelisted catalog-entry fields only (no executables/paths/secrets).
|
|
expect(Object.keys(entry).sort()).toEqual(
|
|
[
|
|
'authState',
|
|
'availability',
|
|
'displayName',
|
|
'harnessId',
|
|
'inputTypes',
|
|
'modelId',
|
|
'providerId',
|
|
'reasoningCapability',
|
|
].sort(),
|
|
);
|
|
assertNoForbiddenLeak(response.body);
|
|
});
|
|
|
|
it('GET catalog for an unknown harnessId returns a typed adapter_unavailable error, never a fallback catalog', async () => {
|
|
const response = await request(app.getHttpServer()).get('/api/harnesses/ghost-harness/catalog');
|
|
|
|
expect(response.status).toBe(404);
|
|
expect(response.body.code).toBe('adapter_unavailable');
|
|
// A fallback catalog would carry a models array; a typed error must not.
|
|
expect(response.body.models).toBeUndefined();
|
|
});
|
|
});
|