333 lines
11 KiB
TypeScript
333 lines
11 KiB
TypeScript
import { type Type } from '@nestjs/common';
|
|
import { Test, type TestingModule } from '@nestjs/testing';
|
|
import type { SlashCommandPayload } from '@mosaicstack/types';
|
|
import { describe, expect, it, vi } from 'vitest';
|
|
import { AgentService, type AgentSession } from '../agent/agent.service.js';
|
|
import { ProviderService } from '../agent/provider.service.js';
|
|
import { AppModule } from '../app.module.js';
|
|
import { CommandAuthorizationService } from '../commands/command-authorization.service.js';
|
|
import { CommandExecutorService } from '../commands/command-executor.service.js';
|
|
import { CommandsModule } from '../commands/commands.module.js';
|
|
import { CommandRuntimeApprovalVerifier } from '../commands/runtime-approval-verifier.js';
|
|
import { PreferencesModule } from '../preferences/preferences.module.js';
|
|
import { SystemOverrideService } from '../preferences/system-override.service.js';
|
|
|
|
const fakeDb = {
|
|
$client: { exec: async (): Promise<void> => {} },
|
|
execute: async (): Promise<{ rows: unknown[] }> => ({ rows: [] }),
|
|
select: () => ({
|
|
from: () => ({
|
|
where: async (): Promise<Array<{ count: number }>> => [{ count: 1 }],
|
|
}),
|
|
}),
|
|
insert: () => ({ values: async (): Promise<void> => {} }),
|
|
};
|
|
|
|
const fakeProviderService = {
|
|
onModuleInit: async (): Promise<void> => {},
|
|
onModuleDestroy: (): void => {},
|
|
getRegistry: () => ({ getAvailable: () => [], getAll: () => [], find: () => undefined }),
|
|
getDefaultModel: () => undefined,
|
|
listAvailableModels: () => [],
|
|
listProviders: () => [],
|
|
getAdapter: () => undefined,
|
|
getProvidersHealth: () => [],
|
|
};
|
|
|
|
function compileRealAppGraph(): Promise<TestingModule> {
|
|
return Test.createTestingModule({ imports: [AppModule] })
|
|
.overrideProvider('DB_HANDLE')
|
|
.useValue({ db: fakeDb, close: async (): Promise<void> => {} })
|
|
.overrideProvider('DB')
|
|
.useValue(fakeDb)
|
|
.overrideProvider('STORAGE_ADAPTER')
|
|
.useValue({
|
|
name: 'required-security-wiring-test',
|
|
migrate: async (): Promise<void> => {},
|
|
close: async (): Promise<void> => {},
|
|
})
|
|
.overrideProvider('AUTH')
|
|
.useValue({})
|
|
.overrideProvider('BRAIN')
|
|
.useValue({ conversations: {}, agents: {} })
|
|
.overrideProvider('LOG_SERVICE')
|
|
.useValue({})
|
|
.overrideProvider('MEMORY')
|
|
.useValue({})
|
|
.overrideProvider('MEMORY_ADAPTER')
|
|
.useValue({})
|
|
.overrideProvider(ProviderService)
|
|
.useValue(fakeProviderService)
|
|
.compile();
|
|
}
|
|
|
|
function providerToken(provider: unknown): unknown {
|
|
return typeof provider === 'function' ? provider : (provider as { provide?: unknown })?.provide;
|
|
}
|
|
|
|
interface MaskingConsumer {
|
|
moduleType: Type<unknown>;
|
|
token: Type<unknown>;
|
|
useValue: object;
|
|
}
|
|
|
|
async function compileWithoutProvider(
|
|
moduleType: Type<unknown>,
|
|
missingToken: Type<unknown>,
|
|
maskingConsumer: MaskingConsumer,
|
|
): Promise<{ error: unknown; moduleRef: TestingModule | undefined }> {
|
|
const touchedModules = new Set([moduleType, maskingConsumer.moduleType]);
|
|
const originals = Array.from(touchedModules, (touchedModule: Type<unknown>) => ({
|
|
moduleType: touchedModule,
|
|
providers: (Reflect.getMetadata('providers', touchedModule) ?? []) as unknown[],
|
|
exports: (Reflect.getMetadata('exports', touchedModule) ?? []) as unknown[],
|
|
}));
|
|
|
|
for (const original of originals) {
|
|
const providers = original.providers.flatMap((provider: unknown): unknown[] => {
|
|
const token = providerToken(provider);
|
|
if (original.moduleType === moduleType && token === missingToken) return [];
|
|
if (original.moduleType === maskingConsumer.moduleType && token === maskingConsumer.token) {
|
|
return [{ provide: maskingConsumer.token, useValue: maskingConsumer.useValue }];
|
|
}
|
|
return [provider];
|
|
});
|
|
const exports = original.exports.filter(
|
|
(exported: unknown): boolean =>
|
|
original.moduleType !== moduleType || providerToken(exported) !== missingToken,
|
|
);
|
|
Reflect.defineMetadata('providers', providers, original.moduleType);
|
|
Reflect.defineMetadata('exports', exports, original.moduleType);
|
|
}
|
|
|
|
let moduleRef: TestingModule | undefined;
|
|
let error: unknown;
|
|
try {
|
|
moduleRef = await compileRealAppGraph();
|
|
} catch (caught: unknown) {
|
|
error = caught;
|
|
} finally {
|
|
for (const original of originals) {
|
|
Reflect.defineMetadata('providers', original.providers, original.moduleType);
|
|
Reflect.defineMetadata('exports', original.exports, original.moduleType);
|
|
}
|
|
}
|
|
return { error, moduleRef };
|
|
}
|
|
|
|
async function closeIfCompiled(moduleRef: TestingModule | undefined): Promise<void> {
|
|
if (moduleRef) await moduleRef.close();
|
|
}
|
|
|
|
describe('required security wiring — real AppModule startup refusal', () => {
|
|
it('FL-01 positive control: the real graph compiles when CommandAuthorizationService is bound', async () => {
|
|
const moduleRef = await compileRealAppGraph();
|
|
try {
|
|
expect(moduleRef.get(CommandAuthorizationService, { strict: false })).toBeInstanceOf(
|
|
CommandAuthorizationService,
|
|
);
|
|
} finally {
|
|
await moduleRef.close();
|
|
}
|
|
});
|
|
|
|
it('FL-01 negative control: absence read as permission is refused at module compilation', async () => {
|
|
const { error, moduleRef } = await compileWithoutProvider(
|
|
CommandsModule,
|
|
CommandAuthorizationService,
|
|
{
|
|
moduleType: CommandsModule,
|
|
token: CommandRuntimeApprovalVerifier,
|
|
useValue: {},
|
|
},
|
|
);
|
|
await closeIfCompiled(moduleRef);
|
|
|
|
expect(
|
|
error,
|
|
'absence read as permission: AppModule compilation accepted a missing CommandAuthorizationService binding',
|
|
).toBeInstanceOf(Error);
|
|
if (!(error instanceof Error)) return;
|
|
expect(error.message).toContain('CommandExecutorService');
|
|
expect(error.message).toContain('CommandAuthorizationService');
|
|
});
|
|
|
|
it('FL-11 positive control: the real graph compiles when SystemOverrideService is bound', async () => {
|
|
const moduleRef = await compileRealAppGraph();
|
|
try {
|
|
expect(moduleRef.get(SystemOverrideService, { strict: false })).toBeInstanceOf(
|
|
SystemOverrideService,
|
|
);
|
|
} finally {
|
|
await moduleRef.close();
|
|
}
|
|
});
|
|
|
|
it('FL-11 negative control: absence read as permission is refused at module compilation', async () => {
|
|
const { error, moduleRef } = await compileWithoutProvider(
|
|
PreferencesModule,
|
|
SystemOverrideService,
|
|
{
|
|
moduleType: CommandsModule,
|
|
token: CommandExecutorService,
|
|
useValue: {},
|
|
},
|
|
);
|
|
await closeIfCompiled(moduleRef);
|
|
|
|
expect(
|
|
error,
|
|
'absence read as permission: AppModule compilation accepted a missing SystemOverrideService binding',
|
|
).toBeInstanceOf(Error);
|
|
if (!(error instanceof Error)) return;
|
|
expect(error.message).toContain('AgentService');
|
|
expect(error.message).toContain('SystemOverrideService');
|
|
});
|
|
});
|
|
|
|
const actorScope = { userId: 'security-user', tenantId: 'security-tenant' };
|
|
const conversationId = 'security-conversation';
|
|
|
|
function directExecutorWithoutAuthorization(systemOverrideSet: ReturnType<typeof vi.fn>) {
|
|
const registry = {
|
|
getManifest: vi.fn(() => ({
|
|
version: 1,
|
|
commands: [
|
|
{
|
|
name: 'system',
|
|
aliases: [],
|
|
description: 'Set instruction authority',
|
|
scope: 'agent' as const,
|
|
execution: 'socket' as const,
|
|
available: true,
|
|
},
|
|
],
|
|
skills: [],
|
|
})),
|
|
};
|
|
return new CommandExecutorService(
|
|
registry as never,
|
|
{ getSession: vi.fn() } as never,
|
|
{ set: systemOverrideSet, clear: vi.fn() } as never,
|
|
{ collect: vi.fn() } as never,
|
|
null,
|
|
{ agents: {} } as never,
|
|
null,
|
|
null,
|
|
{ getServerStatuses: vi.fn(() => []), getToolDefinitions: vi.fn(() => []) } as never,
|
|
undefined as never,
|
|
);
|
|
}
|
|
|
|
function directAgentWithoutSystemOverride(piPrompt: ReturnType<typeof vi.fn>): {
|
|
service: AgentService;
|
|
session: AgentSession;
|
|
} {
|
|
const service = new AgentService(
|
|
{
|
|
getDefaultModel: vi.fn(() => null),
|
|
getRegistry: vi.fn(() => ({})),
|
|
findModel: vi.fn(),
|
|
listAvailableModels: vi.fn(() => []),
|
|
} as never,
|
|
{} as never,
|
|
{} as never,
|
|
{ available: false } as never,
|
|
{} as never,
|
|
{ getToolDefinitions: vi.fn(() => []) } as never,
|
|
{ loadForSession: vi.fn(async () => ({ metaTools: [], promptAdditions: [] })) } as never,
|
|
undefined as never,
|
|
null,
|
|
{ collect: vi.fn().mockResolvedValue(undefined) } as never,
|
|
null,
|
|
);
|
|
const session = {
|
|
id: conversationId,
|
|
provider: 'test-provider',
|
|
modelId: 'test-model',
|
|
piSession: { prompt: piPrompt },
|
|
listeners: new Set(),
|
|
unsubscribe: vi.fn(),
|
|
createdAt: Date.now(),
|
|
promptCount: 0,
|
|
channels: new Set(),
|
|
skillPromptAdditions: [],
|
|
sandboxDir: process.cwd(),
|
|
allowedTools: null,
|
|
userId: actorScope.userId,
|
|
tenantId: actorScope.tenantId,
|
|
metrics: {
|
|
tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
modelSwitches: 0,
|
|
messageCount: 0,
|
|
lastActivityAt: new Date(0).toISOString(),
|
|
},
|
|
} as unknown as AgentSession;
|
|
const internals = service as unknown as { sessions: Map<string, AgentSession> };
|
|
internals.sessions.set(conversationId, session);
|
|
return { service, session };
|
|
}
|
|
|
|
describe('required security wiring — malformed direct absence has zero effects', () => {
|
|
it('FL-01 refuses command execution before any command effect when authorization is absent', async () => {
|
|
const systemOverrideSet = vi.fn().mockResolvedValue(undefined);
|
|
const executor = directExecutorWithoutAuthorization(systemOverrideSet);
|
|
const payload: SlashCommandPayload = {
|
|
command: 'system',
|
|
args: 'authority that must not be stored',
|
|
conversationId,
|
|
};
|
|
let error: unknown;
|
|
|
|
try {
|
|
await executor.execute(payload, actorScope);
|
|
} catch (caught: unknown) {
|
|
error = caught;
|
|
}
|
|
|
|
expect
|
|
.soft(
|
|
error,
|
|
'absence read as permission: direct executor accepted missing command authorization',
|
|
)
|
|
.toBeInstanceOf(Error);
|
|
expect
|
|
.soft(
|
|
systemOverrideSet,
|
|
'absence read as permission: command effect occurred without command authorization',
|
|
)
|
|
.not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('FL-11 refuses prompt execution before any provider or session effect when system override authority is absent', async () => {
|
|
const piPrompt = vi.fn().mockResolvedValue(undefined);
|
|
const { service, session } = directAgentWithoutSystemOverride(piPrompt);
|
|
let error: unknown;
|
|
|
|
try {
|
|
await service.prompt(conversationId, 'must not reach provider', actorScope);
|
|
} catch (caught: unknown) {
|
|
error = caught;
|
|
}
|
|
|
|
expect
|
|
.soft(
|
|
error,
|
|
'absence read as permission: direct session accepted missing system override authority',
|
|
)
|
|
.toBeInstanceOf(Error);
|
|
expect
|
|
.soft(
|
|
piPrompt,
|
|
'absence read as permission: provider prompt occurred without system override authority',
|
|
)
|
|
.not.toHaveBeenCalled();
|
|
expect
|
|
.soft(
|
|
session.promptCount,
|
|
'absence read as permission: session state changed without system override authority',
|
|
)
|
|
.toBe(0);
|
|
});
|
|
});
|