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
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user