chore: consolidate new foundation and archive v1 (#1495)
This commit is contained in:
@@ -0,0 +1,605 @@
|
||||
/**
|
||||
* Integration tests for conversation persistence and context resume (M1-008).
|
||||
*
|
||||
* Verifies the full flow end-to-end using in-memory mocks:
|
||||
* 1. User messages are persisted when sent via ChatGateway.
|
||||
* 2. Assistant responses are persisted with metadata on agent:end.
|
||||
* 3. Conversation history is loaded and injected into context on session resume.
|
||||
* 4. The search endpoint returns matching messages.
|
||||
*/
|
||||
|
||||
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||
import type { ConversationHistoryMessage } from '../agent/agent.service.js';
|
||||
import { ConversationsController } from '../conversations/conversations.controller.js';
|
||||
import type { Message } from '@mosaicstack/brain';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared test data
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const USER_ID = 'user-test-001';
|
||||
const CONV_ID = 'conv-test-001';
|
||||
|
||||
function makeConversation(overrides?: Record<string, unknown>) {
|
||||
return {
|
||||
id: CONV_ID,
|
||||
userId: USER_ID,
|
||||
title: null,
|
||||
projectId: null,
|
||||
archived: false,
|
||||
createdAt: new Date('2026-01-01T00:00:00Z'),
|
||||
updatedAt: new Date('2026-01-01T00:00:00Z'),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeMessage(
|
||||
role: 'user' | 'assistant' | 'system',
|
||||
content: string,
|
||||
overrides?: Record<string, unknown>,
|
||||
) {
|
||||
return {
|
||||
id: `msg-${role}-${Math.random().toString(36).slice(2)}`,
|
||||
conversationId: CONV_ID,
|
||||
role,
|
||||
content,
|
||||
metadata: null,
|
||||
createdAt: new Date('2026-01-01T00:01:00Z'),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: build a mock ConversationsRepo
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function createMockBrain(options?: {
|
||||
conversation?: ReturnType<typeof makeConversation> | undefined;
|
||||
messages?: ReturnType<typeof makeMessage>[];
|
||||
searchResults?: Array<{
|
||||
messageId: string;
|
||||
conversationId: string;
|
||||
conversationTitle: string | null;
|
||||
role: 'user' | 'assistant' | 'system';
|
||||
content: string;
|
||||
createdAt: Date;
|
||||
}>;
|
||||
}) {
|
||||
const conversation = options?.conversation;
|
||||
const messages = options?.messages ?? [];
|
||||
const searchResults = options?.searchResults ?? [];
|
||||
|
||||
return {
|
||||
conversations: {
|
||||
findAll: vi.fn().mockResolvedValue(conversation ? [conversation] : []),
|
||||
findById: vi.fn().mockResolvedValue(conversation),
|
||||
create: vi.fn().mockResolvedValue(conversation ?? makeConversation()),
|
||||
update: vi.fn().mockResolvedValue(conversation),
|
||||
remove: vi.fn().mockResolvedValue(true),
|
||||
findMessages: vi.fn().mockResolvedValue(messages),
|
||||
addMessage: vi.fn().mockImplementation((data: unknown) => {
|
||||
const d = data as {
|
||||
conversationId: string;
|
||||
role: 'user' | 'assistant' | 'system';
|
||||
content: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
return Promise.resolve(makeMessage(d.role, d.content, { metadata: d.metadata ?? null }));
|
||||
}),
|
||||
searchMessages: vi.fn().mockResolvedValue(searchResults),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. ConversationsRepo: addMessage persists user message
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('ConversationsRepo.addMessage — user message persistence', () => {
|
||||
it('persists a user message and returns the saved record', async () => {
|
||||
const brain = createMockBrain({ conversation: makeConversation() });
|
||||
|
||||
const result = await brain.conversations.addMessage(
|
||||
{
|
||||
conversationId: CONV_ID,
|
||||
role: 'user',
|
||||
content: 'Hello, agent!',
|
||||
metadata: { timestamp: '2026-01-01T00:01:00.000Z' },
|
||||
},
|
||||
USER_ID,
|
||||
);
|
||||
|
||||
expect(brain.conversations.addMessage).toHaveBeenCalledOnce();
|
||||
expect(result).toBeDefined();
|
||||
expect(result!.role).toBe('user');
|
||||
expect(result!.content).toBe('Hello, agent!');
|
||||
expect(result!.conversationId).toBe(CONV_ID);
|
||||
});
|
||||
|
||||
it('returns undefined when conversation does not belong to the user', async () => {
|
||||
// Simulate the repo enforcement: ownership mismatch returns undefined
|
||||
const brain = createMockBrain({ conversation: undefined });
|
||||
brain.conversations.addMessage = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
const result = await brain.conversations.addMessage(
|
||||
{ conversationId: CONV_ID, role: 'user', content: 'Hello' },
|
||||
'other-user',
|
||||
);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. ConversationsRepo.addMessage — assistant response with metadata
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('ConversationsRepo.addMessage — assistant response metadata', () => {
|
||||
it('persists assistant message with model, provider, tokens and toolCalls metadata', async () => {
|
||||
const assistantMetadata = {
|
||||
timestamp: '2026-01-01T00:02:00.000Z',
|
||||
model: 'claude-3-5-sonnet-20241022',
|
||||
provider: 'anthropic',
|
||||
toolCalls: [
|
||||
{
|
||||
toolCallId: 'tc-001',
|
||||
toolName: 'read_file',
|
||||
args: { path: '/foo/bar.ts' },
|
||||
isError: false,
|
||||
},
|
||||
],
|
||||
tokenUsage: {
|
||||
input: 1000,
|
||||
output: 250,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
total: 1250,
|
||||
},
|
||||
};
|
||||
|
||||
const brain = createMockBrain({ conversation: makeConversation() });
|
||||
|
||||
const result = await brain.conversations.addMessage(
|
||||
{
|
||||
conversationId: CONV_ID,
|
||||
role: 'assistant',
|
||||
content: 'Here is the file content you requested.',
|
||||
metadata: assistantMetadata,
|
||||
},
|
||||
USER_ID,
|
||||
);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result!.role).toBe('assistant');
|
||||
expect(result!.content).toBe('Here is the file content you requested.');
|
||||
expect(result!.metadata).toMatchObject({
|
||||
model: 'claude-3-5-sonnet-20241022',
|
||||
provider: 'anthropic',
|
||||
tokenUsage: { input: 1000, output: 250, total: 1250 },
|
||||
});
|
||||
expect((result!.metadata as Record<string, unknown>)['toolCalls']).toHaveLength(1);
|
||||
expect(
|
||||
(
|
||||
(result!.metadata as Record<string, unknown>)['toolCalls'] as Array<Record<string, unknown>>
|
||||
)[0]!['toolName'],
|
||||
).toBe('read_file');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 3. ChatGateway.loadConversationHistory — session resume loads history
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Conversation resume — history loading', () => {
|
||||
it('maps DB messages to ConversationHistoryMessage shape', () => {
|
||||
// Simulate what ChatGateway.loadConversationHistory does:
|
||||
// convert DB Message rows to ConversationHistoryMessage for context injection.
|
||||
const dbMessages = [
|
||||
makeMessage('user', 'What is the capital of France?', {
|
||||
createdAt: new Date('2026-01-01T00:01:00Z'),
|
||||
}),
|
||||
makeMessage('assistant', 'The capital of France is Paris.', {
|
||||
createdAt: new Date('2026-01-01T00:01:05Z'),
|
||||
}),
|
||||
makeMessage('user', 'And Germany?', { createdAt: new Date('2026-01-01T00:02:00Z') }),
|
||||
makeMessage('assistant', 'The capital of Germany is Berlin.', {
|
||||
createdAt: new Date('2026-01-01T00:02:05Z'),
|
||||
}),
|
||||
];
|
||||
|
||||
// Replicate the mapping logic from ChatGateway
|
||||
const history: ConversationHistoryMessage[] = dbMessages.map((msg) => ({
|
||||
role: msg.role as 'user' | 'assistant' | 'system',
|
||||
content: msg.content,
|
||||
createdAt: msg.createdAt,
|
||||
}));
|
||||
|
||||
expect(history).toHaveLength(4);
|
||||
expect(history[0]).toEqual({
|
||||
role: 'user',
|
||||
content: 'What is the capital of France?',
|
||||
createdAt: new Date('2026-01-01T00:01:00Z'),
|
||||
});
|
||||
expect(history[1]).toEqual({
|
||||
role: 'assistant',
|
||||
content: 'The capital of France is Paris.',
|
||||
createdAt: new Date('2026-01-01T00:01:05Z'),
|
||||
});
|
||||
expect(history[2]!.role).toBe('user');
|
||||
expect(history[3]!.role).toBe('assistant');
|
||||
});
|
||||
|
||||
it('returns empty array when conversation has no messages', async () => {
|
||||
const brain = createMockBrain({ conversation: makeConversation(), messages: [] });
|
||||
|
||||
const messages = await brain.conversations.findMessages(CONV_ID, USER_ID);
|
||||
expect(messages).toHaveLength(0);
|
||||
|
||||
// Gateway produces empty history → no context injection
|
||||
const history: ConversationHistoryMessage[] = (messages as Message[]).map((msg) => ({
|
||||
role: msg.role as 'user' | 'assistant' | 'system',
|
||||
content: msg.content,
|
||||
createdAt: msg.createdAt,
|
||||
}));
|
||||
expect(history).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('returns empty array when conversation does not belong to the user', async () => {
|
||||
const brain = createMockBrain({ conversation: undefined });
|
||||
brain.conversations.findMessages = vi.fn().mockResolvedValue([]);
|
||||
|
||||
const messages = await brain.conversations.findMessages(CONV_ID, 'other-user');
|
||||
expect(messages).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('preserves message order (ascending by createdAt)', async () => {
|
||||
const ordered = [
|
||||
makeMessage('user', 'First', { createdAt: new Date('2026-01-01T00:01:00Z') }),
|
||||
makeMessage('assistant', 'Second', { createdAt: new Date('2026-01-01T00:01:05Z') }),
|
||||
makeMessage('user', 'Third', { createdAt: new Date('2026-01-01T00:02:00Z') }),
|
||||
];
|
||||
const brain = createMockBrain({ conversation: makeConversation(), messages: ordered });
|
||||
|
||||
const messages = await brain.conversations.findMessages(CONV_ID, USER_ID);
|
||||
expect(messages[0]!.content).toBe('First');
|
||||
expect(messages[1]!.content).toBe('Second');
|
||||
expect(messages[2]!.content).toBe('Third');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 4. AgentService.buildHistoryPromptSection — context injection format
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('AgentService — buildHistoryPromptSection (context injection)', () => {
|
||||
/**
|
||||
* Replicate the private method logic to test it in isolation.
|
||||
* The real method lives in AgentService but is private; we mirror the
|
||||
* exact logic here so the test is independent of the service's constructor.
|
||||
*/
|
||||
function buildHistoryPromptSection(
|
||||
history: ConversationHistoryMessage[],
|
||||
contextWindow: number,
|
||||
_sessionId: string,
|
||||
): string {
|
||||
const TOKEN_BUDGET = Math.floor(contextWindow * 0.8);
|
||||
const HISTORY_HEADER = '## Conversation History (resumed session)\n\n';
|
||||
|
||||
const formatMessage = (msg: ConversationHistoryMessage): string => {
|
||||
const roleLabel =
|
||||
msg.role === 'user' ? 'User' : msg.role === 'assistant' ? 'Assistant' : 'System';
|
||||
return `**${roleLabel}:** ${msg.content}`;
|
||||
};
|
||||
|
||||
const estimateTokens = (text: string) => Math.ceil(text.length / 4);
|
||||
|
||||
const formatted = history.map((msg) => formatMessage(msg));
|
||||
const fullHistory = formatted.join('\n\n');
|
||||
const fullTokens = estimateTokens(HISTORY_HEADER + fullHistory);
|
||||
|
||||
if (fullTokens <= TOKEN_BUDGET) {
|
||||
return HISTORY_HEADER + fullHistory;
|
||||
}
|
||||
|
||||
// History exceeds budget — summarize oldest messages, keep recent verbatim
|
||||
const SUMMARY_RESERVE = Math.floor(TOKEN_BUDGET * 0.2);
|
||||
const verbatimBudget = TOKEN_BUDGET - SUMMARY_RESERVE;
|
||||
|
||||
let verbatimTokens = 0;
|
||||
let verbatimCutIndex = history.length;
|
||||
for (let i = history.length - 1; i >= 0; i--) {
|
||||
const t = estimateTokens(formatted[i]!);
|
||||
if (verbatimTokens + t > verbatimBudget) break;
|
||||
verbatimTokens += t;
|
||||
verbatimCutIndex = i;
|
||||
}
|
||||
|
||||
const summarizedMessages = history.slice(0, verbatimCutIndex);
|
||||
const verbatimMessages = history.slice(verbatimCutIndex);
|
||||
|
||||
let summaryText = '';
|
||||
if (summarizedMessages.length > 0) {
|
||||
const topics = summarizedMessages
|
||||
.filter((m) => m.role === 'user')
|
||||
.map((m) => m.content.slice(0, 120).replace(/\n/g, ' '))
|
||||
.join('; ');
|
||||
summaryText =
|
||||
`**Previous conversation summary** (${summarizedMessages.length} messages omitted for brevity):\n` +
|
||||
`Topics discussed: ${topics || '(no user messages in summarized portion)'}`;
|
||||
}
|
||||
|
||||
const verbatimSection = verbatimMessages.map((m) => formatMessage(m)).join('\n\n');
|
||||
|
||||
const parts: string[] = [HISTORY_HEADER];
|
||||
if (summaryText) parts.push(summaryText);
|
||||
if (verbatimSection) parts.push(verbatimSection);
|
||||
|
||||
return parts.join('\n\n');
|
||||
}
|
||||
|
||||
it('includes header and all messages when history fits within context budget', () => {
|
||||
const history: ConversationHistoryMessage[] = [
|
||||
{ role: 'user', content: 'Hello', createdAt: new Date() },
|
||||
{ role: 'assistant', content: 'Hi there!', createdAt: new Date() },
|
||||
];
|
||||
|
||||
const result = buildHistoryPromptSection(history, 8192, 'session-1');
|
||||
|
||||
expect(result).toContain('## Conversation History (resumed session)');
|
||||
expect(result).toContain('**User:** Hello');
|
||||
expect(result).toContain('**Assistant:** Hi there!');
|
||||
});
|
||||
|
||||
it('labels roles correctly (user, assistant, system)', () => {
|
||||
const history: ConversationHistoryMessage[] = [
|
||||
{ role: 'system', content: 'You are helpful.', createdAt: new Date() },
|
||||
{ role: 'user', content: 'Ping', createdAt: new Date() },
|
||||
{ role: 'assistant', content: 'Pong', createdAt: new Date() },
|
||||
];
|
||||
|
||||
const result = buildHistoryPromptSection(history, 8192, 'session-2');
|
||||
|
||||
expect(result).toContain('**System:** You are helpful.');
|
||||
expect(result).toContain('**User:** Ping');
|
||||
expect(result).toContain('**Assistant:** Pong');
|
||||
});
|
||||
|
||||
it('summarizes old messages when history exceeds 80% of context window', () => {
|
||||
// Create enough messages to exceed a tiny context window budget
|
||||
const longContent = 'A'.repeat(200);
|
||||
const history: ConversationHistoryMessage[] = Array.from({ length: 20 }, (_, i) => ({
|
||||
role: (i % 2 === 0 ? 'user' : 'assistant') as 'user' | 'assistant',
|
||||
content: `${longContent} message ${i}`,
|
||||
createdAt: new Date(),
|
||||
}));
|
||||
|
||||
// Use a small context window so history definitely exceeds 80%
|
||||
const result = buildHistoryPromptSection(history, 512, 'session-3');
|
||||
|
||||
// Should contain the summary prefix
|
||||
expect(result).toContain('messages omitted for brevity');
|
||||
expect(result).toContain('Topics discussed:');
|
||||
});
|
||||
|
||||
it('returns only header for empty history', () => {
|
||||
const result = buildHistoryPromptSection([], 8192, 'session-4');
|
||||
// With empty history, the full history join is '' and the section is just the header
|
||||
expect(result).toContain('## Conversation History (resumed session)');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 5. ConversationsController.search — GET /api/conversations/search
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('ConversationsController — search endpoint', () => {
|
||||
let brain: ReturnType<typeof createMockBrain>;
|
||||
let controller: ConversationsController;
|
||||
|
||||
beforeEach(() => {
|
||||
const searchResults = [
|
||||
{
|
||||
messageId: 'msg-001',
|
||||
conversationId: CONV_ID,
|
||||
conversationTitle: 'Test Chat',
|
||||
role: 'user' as const,
|
||||
content: 'What is the capital of France?',
|
||||
createdAt: new Date('2026-01-01T00:01:00Z'),
|
||||
},
|
||||
{
|
||||
messageId: 'msg-002',
|
||||
conversationId: CONV_ID,
|
||||
conversationTitle: 'Test Chat',
|
||||
role: 'assistant' as const,
|
||||
content: 'The capital of France is Paris.',
|
||||
createdAt: new Date('2026-01-01T00:01:05Z'),
|
||||
},
|
||||
];
|
||||
brain = createMockBrain({ searchResults });
|
||||
controller = new ConversationsController(brain as never, { runtimeMode: 'legacy' });
|
||||
});
|
||||
|
||||
it('returns matching messages for a valid search query', async () => {
|
||||
const results = await controller.search({ q: 'France' }, { id: USER_ID });
|
||||
|
||||
expect(brain.conversations.searchMessages).toHaveBeenCalledWith(USER_ID, 'France', 20, 0);
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results[0]).toMatchObject({
|
||||
messageId: 'msg-001',
|
||||
role: 'user',
|
||||
content: 'What is the capital of France?',
|
||||
});
|
||||
expect(results[1]).toMatchObject({
|
||||
messageId: 'msg-002',
|
||||
role: 'assistant',
|
||||
content: 'The capital of France is Paris.',
|
||||
});
|
||||
});
|
||||
|
||||
it('uses custom limit and offset when provided', async () => {
|
||||
await controller.search({ q: 'Paris', limit: 5, offset: 10 }, { id: USER_ID });
|
||||
|
||||
expect(brain.conversations.searchMessages).toHaveBeenCalledWith(USER_ID, 'Paris', 5, 10);
|
||||
});
|
||||
|
||||
it('throws BadRequestException when query is empty', async () => {
|
||||
await expect(controller.search({ q: '' }, { id: USER_ID })).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
await expect(controller.search({ q: ' ' }, { id: USER_ID })).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
});
|
||||
|
||||
it('trims whitespace from query before passing to repo', async () => {
|
||||
await controller.search({ q: ' Berlin ' }, { id: USER_ID });
|
||||
|
||||
expect(brain.conversations.searchMessages).toHaveBeenCalledWith(
|
||||
USER_ID,
|
||||
'Berlin',
|
||||
expect.any(Number),
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
it('returns empty array when no messages match', async () => {
|
||||
brain.conversations.searchMessages = vi.fn().mockResolvedValue([]);
|
||||
|
||||
const results = await controller.search({ q: 'xyzzy-no-match' }, { id: USER_ID });
|
||||
|
||||
expect(results).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 6. ConversationsController — messages CRUD
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('ConversationsController — message CRUD', () => {
|
||||
it('listMessages returns 404 when conversation is not owned by user', async () => {
|
||||
const brain = createMockBrain({ conversation: undefined });
|
||||
const controller = new ConversationsController(brain as never, { runtimeMode: 'legacy' });
|
||||
|
||||
await expect(controller.listMessages(CONV_ID, { id: USER_ID })).rejects.toBeInstanceOf(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
|
||||
it('listMessages returns the messages for an owned conversation', async () => {
|
||||
const msgs = [makeMessage('user', 'Test message'), makeMessage('assistant', 'Test reply')];
|
||||
const brain = createMockBrain({ conversation: makeConversation(), messages: msgs });
|
||||
const controller = new ConversationsController(brain as never, { runtimeMode: 'legacy' });
|
||||
|
||||
const result = await controller.listMessages(CONV_ID, { id: USER_ID });
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0]!.role).toBe('user');
|
||||
expect(result[1]!.role).toBe('assistant');
|
||||
});
|
||||
|
||||
it('addMessage returns the persisted message', async () => {
|
||||
const brain = createMockBrain({ conversation: makeConversation() });
|
||||
const controller = new ConversationsController(brain as never, { runtimeMode: 'legacy' });
|
||||
|
||||
const result = await controller.addMessage(
|
||||
CONV_ID,
|
||||
{ role: 'user', content: 'Persisted content' },
|
||||
{ id: USER_ID },
|
||||
);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.role).toBe('user');
|
||||
expect(result.content).toBe('Persisted content');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 7. End-to-end persistence flow simulation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('End-to-end persistence flow', () => {
|
||||
it('simulates a full conversation: persist user message → persist assistant response → resume with history', async () => {
|
||||
// ── Step 1: Conversation is created ────────────────────────────────────
|
||||
const brain = createMockBrain({ conversation: makeConversation() });
|
||||
|
||||
await brain.conversations.create({ id: CONV_ID, userId: USER_ID });
|
||||
expect(brain.conversations.create).toHaveBeenCalledOnce();
|
||||
|
||||
// ── Step 2: User message is persisted ──────────────────────────────────
|
||||
const userMsg = await brain.conversations.addMessage(
|
||||
{
|
||||
conversationId: CONV_ID,
|
||||
role: 'user',
|
||||
content: 'Explain monads in simple terms.',
|
||||
metadata: { timestamp: '2026-01-01T00:01:00.000Z' },
|
||||
},
|
||||
USER_ID,
|
||||
);
|
||||
|
||||
expect(userMsg).toBeDefined();
|
||||
expect(userMsg!.role).toBe('user');
|
||||
|
||||
// ── Step 3: Assistant response is persisted with metadata ───────────────
|
||||
const assistantMeta = {
|
||||
timestamp: '2026-01-01T00:01:10.000Z',
|
||||
model: 'claude-3-5-sonnet-20241022',
|
||||
provider: 'anthropic',
|
||||
toolCalls: [],
|
||||
tokenUsage: { input: 500, output: 120, cacheRead: 0, cacheWrite: 0, total: 620 },
|
||||
};
|
||||
|
||||
const assistantMsg = await brain.conversations.addMessage(
|
||||
{
|
||||
conversationId: CONV_ID,
|
||||
role: 'assistant',
|
||||
content: 'A monad is a design pattern that wraps values in a context...',
|
||||
metadata: assistantMeta,
|
||||
},
|
||||
USER_ID,
|
||||
);
|
||||
|
||||
expect(assistantMsg).toBeDefined();
|
||||
expect(assistantMsg!.role).toBe('assistant');
|
||||
|
||||
// ── Step 4: On session resume, history is loaded ────────────────────────
|
||||
const storedMessages = [
|
||||
makeMessage('user', 'Explain monads in simple terms.', {
|
||||
createdAt: new Date('2026-01-01T00:01:00Z'),
|
||||
metadata: { timestamp: '2026-01-01T00:01:00.000Z' },
|
||||
}),
|
||||
makeMessage('assistant', 'A monad is a design pattern that wraps values in a context...', {
|
||||
createdAt: new Date('2026-01-01T00:01:10Z'),
|
||||
metadata: assistantMeta,
|
||||
}),
|
||||
];
|
||||
|
||||
brain.conversations.findMessages = vi.fn().mockResolvedValue(storedMessages);
|
||||
|
||||
const dbMessages = await brain.conversations.findMessages(CONV_ID, USER_ID);
|
||||
expect(dbMessages).toHaveLength(2);
|
||||
|
||||
// ── Step 5: History is mapped for context injection ─────────────────────
|
||||
const history: ConversationHistoryMessage[] = (dbMessages as Message[]).map((msg) => ({
|
||||
role: msg.role as 'user' | 'assistant' | 'system',
|
||||
content: msg.content,
|
||||
createdAt: msg.createdAt,
|
||||
}));
|
||||
|
||||
expect(history[0]).toMatchObject({
|
||||
role: 'user',
|
||||
content: 'Explain monads in simple terms.',
|
||||
});
|
||||
expect(history[1]).toMatchObject({
|
||||
role: 'assistant',
|
||||
content: 'A monad is a design pattern that wraps values in a context...',
|
||||
});
|
||||
|
||||
// ── Step 6: History roles are valid for injection ───────────────────────
|
||||
for (const msg of history) {
|
||||
expect(['user', 'assistant', 'system']).toContain(msg.role);
|
||||
expect(typeof msg.content).toBe('string');
|
||||
expect(msg.createdAt).toBeInstanceOf(Date);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,491 @@
|
||||
/**
|
||||
* Integration test: Cross-user data isolation (M2-007)
|
||||
*
|
||||
* Verifies that every repository query path is scoped to the requesting user —
|
||||
* no user can read, write, or enumerate another user's records.
|
||||
*
|
||||
* Test strategy:
|
||||
* - Two real users (User A, User B) are inserted directly into the database.
|
||||
* - Realistic data (conversations + messages, agent configs, preferences,
|
||||
* insights) is created for each user.
|
||||
* - A shared system agent is inserted so both users can see it via
|
||||
* findAccessible().
|
||||
* - All assertions are made against the live database (no mocks).
|
||||
* - All inserted rows are cleaned up in the afterAll hook.
|
||||
*
|
||||
* Requires: DATABASE_URL pointing at a running PostgreSQL instance with
|
||||
* pgvector enabled and the Mosaic schema already applied.
|
||||
*/
|
||||
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { createDb } from '@mosaicstack/db';
|
||||
import { createConversationsRepo } from '@mosaicstack/brain';
|
||||
import { createAgentsRepo } from '@mosaicstack/brain';
|
||||
import { createPreferencesRepo, createInsightsRepo } from '@mosaicstack/memory';
|
||||
import { users, conversations, messages, agents, preferences, insights } from '@mosaicstack/db';
|
||||
import { eq } from '@mosaicstack/db';
|
||||
import type { DbHandle } from '@mosaicstack/db';
|
||||
|
||||
// ─── Fixed IDs so the afterAll cleanup is deterministic ──────────────────────
|
||||
|
||||
const USER_A_ID = 'test-iso-user-a';
|
||||
const USER_B_ID = 'test-iso-user-b';
|
||||
const CONV_A_ID = 'aaaaaaaa-0000-0000-0000-000000000001';
|
||||
const CONV_B_ID = 'bbbbbbbb-0000-0000-0000-000000000001';
|
||||
const MSG_A_ID = 'aaaaaaaa-0000-0000-0000-000000000002';
|
||||
const MSG_B_ID = 'bbbbbbbb-0000-0000-0000-000000000002';
|
||||
const AGENT_A_ID = 'aaaaaaaa-0000-0000-0000-000000000003';
|
||||
const AGENT_B_ID = 'bbbbbbbb-0000-0000-0000-000000000003';
|
||||
const AGENT_SYS_ID = 'ffffffff-0000-0000-0000-000000000001';
|
||||
const PREF_A_ID = 'aaaaaaaa-0000-0000-0000-000000000004';
|
||||
const PREF_B_ID = 'bbbbbbbb-0000-0000-0000-000000000004';
|
||||
const INSIGHT_A_ID = 'aaaaaaaa-0000-0000-0000-000000000005';
|
||||
const INSIGHT_B_ID = 'bbbbbbbb-0000-0000-0000-000000000005';
|
||||
|
||||
// ─── Test fixture ─────────────────────────────────────────────────────────────
|
||||
|
||||
let handle: DbHandle;
|
||||
let dbAvailable = false;
|
||||
|
||||
beforeAll(async () => {
|
||||
try {
|
||||
handle = createDb();
|
||||
const db = handle.db;
|
||||
|
||||
// Insert two users
|
||||
await db
|
||||
.insert(users)
|
||||
.values([
|
||||
{
|
||||
id: USER_A_ID,
|
||||
name: 'Isolation Test User A',
|
||||
email: '[email protected]',
|
||||
emailVerified: false,
|
||||
},
|
||||
{
|
||||
id: USER_B_ID,
|
||||
name: 'Isolation Test User B',
|
||||
email: '[email protected]',
|
||||
emailVerified: false,
|
||||
},
|
||||
])
|
||||
.onConflictDoNothing();
|
||||
|
||||
// Conversations — one per user
|
||||
await db
|
||||
.insert(conversations)
|
||||
.values([
|
||||
{ id: CONV_A_ID, userId: USER_A_ID, title: 'User A conversation' },
|
||||
{ id: CONV_B_ID, userId: USER_B_ID, title: 'User B conversation' },
|
||||
])
|
||||
.onConflictDoNothing();
|
||||
|
||||
// Messages — one per conversation
|
||||
await db
|
||||
.insert(messages)
|
||||
.values([
|
||||
{
|
||||
id: MSG_A_ID,
|
||||
conversationId: CONV_A_ID,
|
||||
role: 'user',
|
||||
content: 'Hello from User A',
|
||||
},
|
||||
{
|
||||
id: MSG_B_ID,
|
||||
conversationId: CONV_B_ID,
|
||||
role: 'user',
|
||||
content: 'Hello from User B',
|
||||
},
|
||||
])
|
||||
.onConflictDoNothing();
|
||||
|
||||
// Agent configs — private agents (one per user) + one system agent
|
||||
await db
|
||||
.insert(agents)
|
||||
.values([
|
||||
{
|
||||
id: AGENT_A_ID,
|
||||
name: 'Agent A (private)',
|
||||
provider: 'test',
|
||||
model: 'test-model',
|
||||
ownerId: USER_A_ID,
|
||||
isSystem: false,
|
||||
},
|
||||
{
|
||||
id: AGENT_B_ID,
|
||||
name: 'Agent B (private)',
|
||||
provider: 'test',
|
||||
model: 'test-model',
|
||||
ownerId: USER_B_ID,
|
||||
isSystem: false,
|
||||
},
|
||||
{
|
||||
id: AGENT_SYS_ID,
|
||||
name: 'Shared System Agent',
|
||||
provider: 'test',
|
||||
model: 'test-model',
|
||||
ownerId: null,
|
||||
isSystem: true,
|
||||
},
|
||||
])
|
||||
.onConflictDoNothing();
|
||||
|
||||
// Preferences — one per user (same key, different values)
|
||||
await db
|
||||
.insert(preferences)
|
||||
.values([
|
||||
{
|
||||
id: PREF_A_ID,
|
||||
userId: USER_A_ID,
|
||||
key: 'theme',
|
||||
value: 'dark',
|
||||
category: 'appearance',
|
||||
},
|
||||
{
|
||||
id: PREF_B_ID,
|
||||
userId: USER_B_ID,
|
||||
key: 'theme',
|
||||
value: 'light',
|
||||
category: 'appearance',
|
||||
},
|
||||
])
|
||||
.onConflictDoNothing();
|
||||
|
||||
// Insights — no embedding to keep the fixture simple; embedding-based search
|
||||
// is tested separately with a zero-vector that falls outside maxDistance
|
||||
await db
|
||||
.insert(insights)
|
||||
.values([
|
||||
{
|
||||
id: INSIGHT_A_ID,
|
||||
userId: USER_A_ID,
|
||||
content: 'User A insight',
|
||||
source: 'user',
|
||||
category: 'general',
|
||||
relevanceScore: 1.0,
|
||||
},
|
||||
{
|
||||
id: INSIGHT_B_ID,
|
||||
userId: USER_B_ID,
|
||||
content: 'User B insight',
|
||||
source: 'user',
|
||||
category: 'general',
|
||||
relevanceScore: 1.0,
|
||||
},
|
||||
])
|
||||
.onConflictDoNothing();
|
||||
|
||||
dbAvailable = true;
|
||||
} catch {
|
||||
// Database is not reachable (e.g., CI environment without Postgres on port 5433).
|
||||
// All tests in this suite will be skipped.
|
||||
}
|
||||
});
|
||||
|
||||
// Skip all tests in this file when the database is not reachable (e.g., CI without Postgres).
|
||||
beforeEach((ctx) => {
|
||||
if (!dbAvailable) {
|
||||
ctx.skip();
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// Cleanup only when the fixture actually installed rows. `handle` is set
|
||||
// before the first query (createDb connects lazily), so on an unreachable
|
||||
// database `handle` is truthy while nothing was inserted — cleanup must
|
||||
// honor `dbAvailable` or the skip path fails the file with ECONNREFUSED in
|
||||
// afterAll (caught live by the publish pipeline's no-DATABASE_URL verify
|
||||
// step, pipeline 2486).
|
||||
if (!handle || !dbAvailable) return;
|
||||
const db = handle.db;
|
||||
|
||||
// Delete in dependency order (FK constraints)
|
||||
await db.delete(messages).where(eq(messages.id, MSG_A_ID));
|
||||
await db.delete(messages).where(eq(messages.id, MSG_B_ID));
|
||||
await db.delete(conversations).where(eq(conversations.id, CONV_A_ID));
|
||||
await db.delete(conversations).where(eq(conversations.id, CONV_B_ID));
|
||||
await db.delete(agents).where(eq(agents.id, AGENT_A_ID));
|
||||
await db.delete(agents).where(eq(agents.id, AGENT_B_ID));
|
||||
await db.delete(agents).where(eq(agents.id, AGENT_SYS_ID));
|
||||
await db.delete(preferences).where(eq(preferences.id, PREF_A_ID));
|
||||
await db.delete(preferences).where(eq(preferences.id, PREF_B_ID));
|
||||
await db.delete(insights).where(eq(insights.id, INSIGHT_A_ID));
|
||||
await db.delete(insights).where(eq(insights.id, INSIGHT_B_ID));
|
||||
await db.delete(users).where(eq(users.id, USER_A_ID));
|
||||
await db.delete(users).where(eq(users.id, USER_B_ID));
|
||||
|
||||
await handle.close();
|
||||
});
|
||||
|
||||
// ─── Conversations isolation ──────────────────────────────────────────────────
|
||||
|
||||
describe('ConversationsRepo — cross-user isolation', () => {
|
||||
it('User A can find their own conversation by id', async () => {
|
||||
const repo = createConversationsRepo(handle.db);
|
||||
const conv = await repo.findById(CONV_A_ID, USER_A_ID);
|
||||
expect(conv).toBeDefined();
|
||||
expect(conv!.id).toBe(CONV_A_ID);
|
||||
});
|
||||
|
||||
it('User B cannot find User A conversation by id (returns undefined)', async () => {
|
||||
const repo = createConversationsRepo(handle.db);
|
||||
const conv = await repo.findById(CONV_A_ID, USER_B_ID);
|
||||
expect(conv).toBeUndefined();
|
||||
});
|
||||
|
||||
it('User A cannot find User B conversation by id (returns undefined)', async () => {
|
||||
const repo = createConversationsRepo(handle.db);
|
||||
const conv = await repo.findById(CONV_B_ID, USER_A_ID);
|
||||
expect(conv).toBeUndefined();
|
||||
});
|
||||
|
||||
it('findAll returns only own conversations for User A', async () => {
|
||||
const repo = createConversationsRepo(handle.db);
|
||||
const convs = await repo.findAll(USER_A_ID);
|
||||
const ids = convs.map((c) => c.id);
|
||||
expect(ids).toContain(CONV_A_ID);
|
||||
expect(ids).not.toContain(CONV_B_ID);
|
||||
});
|
||||
|
||||
it('findAll returns only own conversations for User B', async () => {
|
||||
const repo = createConversationsRepo(handle.db);
|
||||
const convs = await repo.findAll(USER_B_ID);
|
||||
const ids = convs.map((c) => c.id);
|
||||
expect(ids).toContain(CONV_B_ID);
|
||||
expect(ids).not.toContain(CONV_A_ID);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Messages isolation ───────────────────────────────────────────────────────
|
||||
|
||||
describe('ConversationsRepo.findMessages — cross-user isolation', () => {
|
||||
it('User A can read messages from their own conversation', async () => {
|
||||
const repo = createConversationsRepo(handle.db);
|
||||
const msgs = await repo.findMessages(CONV_A_ID, USER_A_ID);
|
||||
const ids = msgs.map((m) => m.id);
|
||||
expect(ids).toContain(MSG_A_ID);
|
||||
});
|
||||
|
||||
it('User B cannot read messages from User A conversation (returns empty array)', async () => {
|
||||
const repo = createConversationsRepo(handle.db);
|
||||
const msgs = await repo.findMessages(CONV_A_ID, USER_B_ID);
|
||||
expect(msgs).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('User A cannot read messages from User B conversation (returns empty array)', async () => {
|
||||
const repo = createConversationsRepo(handle.db);
|
||||
const msgs = await repo.findMessages(CONV_B_ID, USER_A_ID);
|
||||
expect(msgs).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('addMessage is rejected when user does not own the conversation', async () => {
|
||||
const repo = createConversationsRepo(handle.db);
|
||||
const result = await repo.addMessage(
|
||||
{
|
||||
conversationId: CONV_A_ID,
|
||||
role: 'user',
|
||||
content: 'Attempted injection by User B',
|
||||
},
|
||||
USER_B_ID,
|
||||
);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Agent configs isolation ──────────────────────────────────────────────────
|
||||
|
||||
describe('AgentsRepo.findAccessible — cross-user isolation', () => {
|
||||
it('User A sees their own private agent', async () => {
|
||||
const repo = createAgentsRepo(handle.db);
|
||||
const accessible = await repo.findAccessible(USER_A_ID);
|
||||
const ids = accessible.map((a) => a.id);
|
||||
expect(ids).toContain(AGENT_A_ID);
|
||||
});
|
||||
|
||||
it('User A does NOT see User B private agent', async () => {
|
||||
const repo = createAgentsRepo(handle.db);
|
||||
const accessible = await repo.findAccessible(USER_A_ID);
|
||||
const ids = accessible.map((a) => a.id);
|
||||
expect(ids).not.toContain(AGENT_B_ID);
|
||||
});
|
||||
|
||||
it('User B does NOT see User A private agent', async () => {
|
||||
const repo = createAgentsRepo(handle.db);
|
||||
const accessible = await repo.findAccessible(USER_B_ID);
|
||||
const ids = accessible.map((a) => a.id);
|
||||
expect(ids).not.toContain(AGENT_A_ID);
|
||||
});
|
||||
|
||||
it('Both users can see the shared system agent', async () => {
|
||||
const repo = createAgentsRepo(handle.db);
|
||||
const accessibleA = await repo.findAccessible(USER_A_ID);
|
||||
const accessibleB = await repo.findAccessible(USER_B_ID);
|
||||
expect(accessibleA.map((a) => a.id)).toContain(AGENT_SYS_ID);
|
||||
expect(accessibleB.map((a) => a.id)).toContain(AGENT_SYS_ID);
|
||||
});
|
||||
|
||||
it('findSystem returns the system agent for any caller', async () => {
|
||||
const repo = createAgentsRepo(handle.db);
|
||||
const system = await repo.findSystem();
|
||||
const ids = system.map((a) => a.id);
|
||||
expect(ids).toContain(AGENT_SYS_ID);
|
||||
});
|
||||
|
||||
it('update with ownerId prevents User B from modifying User A agent', async () => {
|
||||
const repo = createAgentsRepo(handle.db);
|
||||
const result = await repo.update(AGENT_A_ID, { model: 'hacked' }, USER_B_ID);
|
||||
expect(result).toBeUndefined();
|
||||
|
||||
// Verify the agent was not actually mutated
|
||||
const unchanged = await repo.findById(AGENT_A_ID);
|
||||
expect(unchanged?.model).toBe('test-model');
|
||||
});
|
||||
|
||||
it('remove prevents User B from deleting User A agent', async () => {
|
||||
const repo = createAgentsRepo(handle.db);
|
||||
const deleted = await repo.remove(AGENT_A_ID, USER_B_ID);
|
||||
expect(deleted).toBe(false);
|
||||
|
||||
// Verify the agent still exists
|
||||
const still = await repo.findById(AGENT_A_ID);
|
||||
expect(still).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Preferences isolation ────────────────────────────────────────────────────
|
||||
|
||||
describe('PreferencesRepo — cross-user isolation', () => {
|
||||
it('User A can retrieve their own preferences', async () => {
|
||||
const repo = createPreferencesRepo(handle.db);
|
||||
const prefs = await repo.findByUser(USER_A_ID);
|
||||
const ids = prefs.map((p) => p.id);
|
||||
expect(ids).toContain(PREF_A_ID);
|
||||
});
|
||||
|
||||
it('User A preferences do not contain User B preferences', async () => {
|
||||
const repo = createPreferencesRepo(handle.db);
|
||||
const prefs = await repo.findByUser(USER_A_ID);
|
||||
const ids = prefs.map((p) => p.id);
|
||||
expect(ids).not.toContain(PREF_B_ID);
|
||||
});
|
||||
|
||||
it('User B preferences do not contain User A preferences', async () => {
|
||||
const repo = createPreferencesRepo(handle.db);
|
||||
const prefs = await repo.findByUser(USER_B_ID);
|
||||
const ids = prefs.map((p) => p.id);
|
||||
expect(ids).not.toContain(PREF_A_ID);
|
||||
});
|
||||
|
||||
it('findByUserAndKey is scoped to the requesting user', async () => {
|
||||
const repo = createPreferencesRepo(handle.db);
|
||||
// Both users have key "theme" — each should only see their own value
|
||||
const prefA = await repo.findByUserAndKey(USER_A_ID, 'theme');
|
||||
const prefB = await repo.findByUserAndKey(USER_B_ID, 'theme');
|
||||
|
||||
expect(prefA).toBeDefined();
|
||||
// Drizzle returns JSONB values as parsed JS values; '"dark"' (JSON string) → 'dark'
|
||||
expect(prefA!.value).toBe('dark');
|
||||
expect(prefB).toBeDefined();
|
||||
expect(prefB!.value).toBe('light');
|
||||
});
|
||||
|
||||
it('remove is scoped to the requesting user (cannot delete another user pref)', async () => {
|
||||
const repo = createPreferencesRepo(handle.db);
|
||||
// User B tries to delete User A's "theme" preference — should silently fail
|
||||
const deleted = await repo.remove(USER_B_ID, 'theme');
|
||||
// This only deletes USER_B's own "theme" row; re-insert it for afterAll cleanup
|
||||
expect(deleted).toBe(true); // deletes User B's OWN theme pref
|
||||
|
||||
// User A's theme pref must be untouched
|
||||
const prefA = await repo.findByUserAndKey(USER_A_ID, 'theme');
|
||||
expect(prefA).toBeDefined();
|
||||
|
||||
// Re-insert User B's preference so afterAll cleanup still finds it
|
||||
await repo.upsert({
|
||||
id: PREF_B_ID,
|
||||
userId: USER_B_ID,
|
||||
key: 'theme',
|
||||
value: 'light',
|
||||
category: 'appearance',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Insights isolation ───────────────────────────────────────────────────────
|
||||
|
||||
describe('InsightsRepo — cross-user isolation', () => {
|
||||
it('User A can retrieve their own insights', async () => {
|
||||
const repo = createInsightsRepo(handle.db);
|
||||
const list = await repo.findByUser(USER_A_ID);
|
||||
const ids = list.map((i) => i.id);
|
||||
expect(ids).toContain(INSIGHT_A_ID);
|
||||
});
|
||||
|
||||
it('User A insights do not contain User B insights', async () => {
|
||||
const repo = createInsightsRepo(handle.db);
|
||||
const list = await repo.findByUser(USER_A_ID);
|
||||
const ids = list.map((i) => i.id);
|
||||
expect(ids).not.toContain(INSIGHT_B_ID);
|
||||
});
|
||||
|
||||
it('User B insights do not contain User A insights', async () => {
|
||||
const repo = createInsightsRepo(handle.db);
|
||||
const list = await repo.findByUser(USER_B_ID);
|
||||
const ids = list.map((i) => i.id);
|
||||
expect(ids).not.toContain(INSIGHT_A_ID);
|
||||
});
|
||||
|
||||
it('findById is scoped to the requesting user', async () => {
|
||||
const repo = createInsightsRepo(handle.db);
|
||||
const own = await repo.findById(INSIGHT_A_ID, USER_A_ID);
|
||||
const cross = await repo.findById(INSIGHT_A_ID, USER_B_ID);
|
||||
|
||||
expect(own).toBeDefined();
|
||||
expect(cross).toBeUndefined();
|
||||
});
|
||||
|
||||
it('searchByEmbedding returns only own insights', async () => {
|
||||
const repo = createInsightsRepo(handle.db);
|
||||
// Our test insights have no embedding — the query filters WHERE embedding IS NOT NULL
|
||||
// so the result set is empty, which already proves no cross-user leakage.
|
||||
// Using a 1536-dimension zero vector as the query embedding.
|
||||
const zeroVector = Array<number>(1536).fill(0);
|
||||
|
||||
const resultsA = await repo.searchByEmbedding(USER_A_ID, zeroVector, 50, 2.0);
|
||||
const resultsB = await repo.searchByEmbedding(USER_B_ID, zeroVector, 50, 2.0);
|
||||
|
||||
// The raw SQL query returns row objects directly (not wrapped in { insight }).
|
||||
// Cast via unknown to extract id safely regardless of the return shape.
|
||||
const toId = (r: unknown): string =>
|
||||
((r as Record<string, unknown>)['id'] as string | undefined) ??
|
||||
((r as Record<string, Record<string, unknown>>)['insight']?.['id'] as string | undefined) ??
|
||||
'';
|
||||
const idsInA = resultsA.map(toId);
|
||||
const idsInB = resultsB.map(toId);
|
||||
|
||||
// User B's insight must never appear in User A's search results
|
||||
expect(idsInA).not.toContain(INSIGHT_B_ID);
|
||||
// User A's insight must never appear in User B's search results
|
||||
expect(idsInB).not.toContain(INSIGHT_A_ID);
|
||||
});
|
||||
|
||||
it('update is scoped to the requesting user', async () => {
|
||||
const repo = createInsightsRepo(handle.db);
|
||||
const result = await repo.update(INSIGHT_A_ID, USER_B_ID, { content: 'hacked' });
|
||||
expect(result).toBeUndefined();
|
||||
|
||||
// Verify the insight was not mutated
|
||||
const unchanged = await repo.findById(INSIGHT_A_ID, USER_A_ID);
|
||||
expect(unchanged?.content).toBe('User A insight');
|
||||
});
|
||||
|
||||
it('remove is scoped to the requesting user', async () => {
|
||||
const repo = createInsightsRepo(handle.db);
|
||||
const deleted = await repo.remove(INSIGHT_A_ID, USER_B_ID);
|
||||
expect(deleted).toBe(false);
|
||||
|
||||
// Verify the insight still exists
|
||||
const still = await repo.findById(INSIGHT_A_ID, USER_A_ID);
|
||||
expect(still).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
import { RequestMethod, type Type } from '@nestjs/common';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { AppModule } from '../app.module.js';
|
||||
import { HierarchyModule } from '../hierarchy/hierarchy.module.js';
|
||||
|
||||
/**
|
||||
* Hierarchy route inventory (contract 1 §6.3).
|
||||
*
|
||||
* The hierarchy command family is a CLOSED enumeration asserted here, not a
|
||||
* prose claim: every hierarchy-flavored route the AppModule graph declares
|
||||
* must appear in HIERARCHY_COMMAND_FAMILY, and vice versa. Adding or
|
||||
* removing a hierarchy route without updating this inventory (and its
|
||||
* witnesses) fails CI first. This replaces the M4-1b-i zero-routes
|
||||
* baseline.
|
||||
*/
|
||||
|
||||
interface RouteEntry {
|
||||
method: string;
|
||||
path: string;
|
||||
controller: string;
|
||||
}
|
||||
|
||||
/** Module-metadata entry: a module class or a DynamicModule-shaped object. */
|
||||
type ModuleEntry =
|
||||
| Type<unknown>
|
||||
| { module: Type<unknown>; imports?: unknown[]; controllers?: Type<unknown>[] };
|
||||
|
||||
function collectControllers(root: ModuleEntry): Type<unknown>[] {
|
||||
const visited = new Set<unknown>();
|
||||
const controllers: Type<unknown>[] = [];
|
||||
const walk = (entry: ModuleEntry | undefined | null): void => {
|
||||
if (!entry || visited.has(entry)) return;
|
||||
visited.add(entry);
|
||||
const moduleClass = typeof entry === 'function' ? entry : entry.module;
|
||||
// Entries with no resolvable class (forwardRef wrappers, async dynamic
|
||||
// modules) carry no decorator metadata to read here.
|
||||
if (typeof moduleClass !== 'function') return;
|
||||
if (visited.has(moduleClass) && typeof entry !== 'function') return;
|
||||
visited.add(moduleClass);
|
||||
// 'controllers' / 'imports' are the metadata keys the @Module decorator writes.
|
||||
const declared = (Reflect.getMetadata('controllers', moduleClass) ?? []) as Type<unknown>[];
|
||||
controllers.push(...declared);
|
||||
if (typeof entry !== 'function' && entry.controllers) controllers.push(...entry.controllers);
|
||||
const imports = [
|
||||
...((Reflect.getMetadata('imports', moduleClass) ?? []) as ModuleEntry[]),
|
||||
...(typeof entry !== 'function' ? ((entry.imports ?? []) as ModuleEntry[]) : []),
|
||||
];
|
||||
for (const imported of imports) walk(imported);
|
||||
};
|
||||
walk(root);
|
||||
return controllers;
|
||||
}
|
||||
|
||||
function routesOf(controller: Type<unknown>): RouteEntry[] {
|
||||
// 'path' on the class is the @Controller prefix; 'path'/'method' on a
|
||||
// handler are written by the @Get/@Post/... route decorators.
|
||||
const base = (Reflect.getMetadata('path', controller) ?? '') as string | string[];
|
||||
const bases = Array.isArray(base) ? base : [base];
|
||||
const routes: RouteEntry[] = [];
|
||||
const prototype = controller.prototype as Record<string, unknown>;
|
||||
for (const name of Object.getOwnPropertyNames(prototype)) {
|
||||
if (name === 'constructor') continue;
|
||||
const handler = Object.getOwnPropertyDescriptor(prototype, name)?.value;
|
||||
if (typeof handler !== 'function') continue;
|
||||
const method = Reflect.getMetadata('method', handler) as number | undefined;
|
||||
if (method === undefined) continue;
|
||||
const sub = (Reflect.getMetadata('path', handler) ?? '/') as string;
|
||||
for (const prefix of bases) {
|
||||
const path = `/${prefix}/${sub}`.replace(/\/+/g, '/').replace(/(.)\/$/, '$1');
|
||||
routes.push({
|
||||
method: RequestMethod[method] ?? String(method),
|
||||
path,
|
||||
controller: controller.name,
|
||||
});
|
||||
}
|
||||
}
|
||||
return routes;
|
||||
}
|
||||
|
||||
/**
|
||||
* The closed command family (contract 1 §5, M4-1b-ii). Every entry is a
|
||||
* mutation audited via the M4-1b-i path or one of the two ratified reads
|
||||
* (granted companies, the §2.8 directory carve-out).
|
||||
*/
|
||||
const HIERARCHY_COMMAND_FAMILY = [
|
||||
'POST /api/hierarchy/companies',
|
||||
'GET /api/hierarchy/companies',
|
||||
'GET /api/hierarchy/companies/directory',
|
||||
'POST /api/hierarchy/companies/:id/rename',
|
||||
'POST /api/hierarchy/companies/:id/visibility',
|
||||
'DELETE /api/hierarchy/companies/:id',
|
||||
'POST /api/hierarchy/estates',
|
||||
'POST /api/hierarchy/estates/:id/rename',
|
||||
'POST /api/hierarchy/estates/:id/transfer',
|
||||
'DELETE /api/hierarchy/estates/:id',
|
||||
'POST /api/hierarchy/platform-projects',
|
||||
'POST /api/hierarchy/platform-projects/:id/rename',
|
||||
'POST /api/hierarchy/platform-projects/:id/transfer',
|
||||
'DELETE /api/hierarchy/platform-projects/:id',
|
||||
'POST /api/hierarchy/grants',
|
||||
'POST /api/hierarchy/grants/:id/change',
|
||||
'DELETE /api/hierarchy/grants/:id',
|
||||
] as const;
|
||||
|
||||
describe('hierarchy route inventory (§6.3)', () => {
|
||||
const inventory = collectControllers(AppModule).flatMap(routesOf);
|
||||
|
||||
it('control: the enumeration sees the known route surface', () => {
|
||||
const paths = inventory.map((r) => `${r.method} ${r.path}`);
|
||||
expect(paths).toContain('GET /health');
|
||||
expect(paths).toContain('POST /api/workspaces');
|
||||
expect(paths).toContain('GET /api/teams');
|
||||
expect(inventory.length).toBeGreaterThan(20);
|
||||
});
|
||||
|
||||
it('the hierarchy surface is exactly the declared command family', () => {
|
||||
const hierarchyRoutes = inventory
|
||||
.filter((r) => /hierarch|compan|estate|platform[-_]?project/i.test(r.path))
|
||||
.map((r) => `${r.method} ${r.path}`)
|
||||
.sort();
|
||||
expect(hierarchyRoutes).toEqual([...HIERARCHY_COMMAND_FAMILY].sort());
|
||||
});
|
||||
|
||||
it('every command-family route lives on HierarchyController inside HierarchyModule', () => {
|
||||
const controllers = collectControllers(HierarchyModule);
|
||||
expect(controllers.map((c) => c.name)).toEqual(['HierarchyController']);
|
||||
const declared = controllers
|
||||
.flatMap(routesOf)
|
||||
.map((r) => `${r.method} ${r.path}`)
|
||||
.sort();
|
||||
expect(declared).toEqual([...HIERARCHY_COMMAND_FAMILY].sort());
|
||||
});
|
||||
});
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Test B — Gateway boot refuses (fail-fast) when PG is unreachable.
|
||||
*
|
||||
* Prereq: docker compose -f docker-compose.federated.yml --profile federated up -d
|
||||
* (Valkey must be running; only PG is intentionally misconfigured.)
|
||||
* Run: FEDERATED_INTEGRATION=1 pnpm --filter @mosaicstack/gateway test src/__tests__/integration/federated-boot.pg-unreachable.integration.test.ts
|
||||
*
|
||||
* Skipped when FEDERATED_INTEGRATION !== '1'.
|
||||
*/
|
||||
|
||||
import net from 'node:net';
|
||||
import { beforeAll, describe, expect, it } from 'vitest';
|
||||
import { TierDetectionError, detectAndAssertTier } from '@mosaicstack/storage';
|
||||
|
||||
const run = process.env['FEDERATED_INTEGRATION'] === '1';
|
||||
|
||||
const VALKEY_URL = 'redis://localhost:6380';
|
||||
|
||||
/**
|
||||
* Reserves a guaranteed-closed port at runtime by binding to an ephemeral OS
|
||||
* port (port 0) and immediately releasing it. The OS will not reassign the
|
||||
* port during the TIME_WAIT window, so it remains closed for the duration of
|
||||
* this test.
|
||||
*/
|
||||
async function reserveClosedPort(): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const addr = server.address();
|
||||
if (typeof addr !== 'object' || !addr) return reject(new Error('no addr'));
|
||||
const port = addr.port;
|
||||
server.close(() => resolve(port));
|
||||
});
|
||||
server.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
describe.skipIf(!run)('federated boot — PG unreachable', () => {
|
||||
let badPgUrl: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
const closedPort = await reserveClosedPort();
|
||||
badPgUrl = `postgresql://mosaic:mosaic@localhost:${closedPort}/mosaic`;
|
||||
});
|
||||
|
||||
it('detectAndAssertTier throws TierDetectionError with service: postgres when PG is down', async () => {
|
||||
const brokenConfig = {
|
||||
tier: 'federated' as const,
|
||||
storage: {
|
||||
type: 'postgres' as const,
|
||||
url: badPgUrl,
|
||||
enableVector: true,
|
||||
},
|
||||
queue: {
|
||||
type: 'bullmq',
|
||||
url: VALKEY_URL,
|
||||
},
|
||||
};
|
||||
|
||||
await expect(detectAndAssertTier(brokenConfig)).rejects.toSatisfy(
|
||||
(err: unknown) => err instanceof TierDetectionError && err.service === 'postgres',
|
||||
);
|
||||
}, 10_000);
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Test A — Gateway boot succeeds when federated services are up.
|
||||
*
|
||||
* Prereq: docker compose -f docker-compose.federated.yml --profile federated up -d
|
||||
* Run: FEDERATED_INTEGRATION=1 pnpm --filter @mosaicstack/gateway test src/__tests__/integration/federated-boot.success.integration.test.ts
|
||||
*
|
||||
* Skipped when FEDERATED_INTEGRATION !== '1'.
|
||||
*/
|
||||
|
||||
import postgres from 'postgres';
|
||||
import { afterAll, describe, expect, it } from 'vitest';
|
||||
import { detectAndAssertTier } from '@mosaicstack/storage';
|
||||
|
||||
const run = process.env['FEDERATED_INTEGRATION'] === '1';
|
||||
|
||||
const PG_URL = 'postgresql://mosaic:mosaic@localhost:5433/mosaic';
|
||||
const VALKEY_URL = 'redis://localhost:6380';
|
||||
|
||||
const federatedConfig = {
|
||||
tier: 'federated' as const,
|
||||
storage: {
|
||||
type: 'postgres' as const,
|
||||
url: PG_URL,
|
||||
enableVector: true,
|
||||
},
|
||||
queue: {
|
||||
type: 'bullmq',
|
||||
url: VALKEY_URL,
|
||||
},
|
||||
};
|
||||
|
||||
describe.skipIf(!run)('federated boot — success path', () => {
|
||||
let sql: ReturnType<typeof postgres> | undefined;
|
||||
|
||||
afterAll(async () => {
|
||||
if (sql) {
|
||||
await sql.end({ timeout: 2 }).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
it('detectAndAssertTier resolves without throwing when federated services are up', async () => {
|
||||
await expect(detectAndAssertTier(federatedConfig)).resolves.toBeUndefined();
|
||||
}, 10_000);
|
||||
|
||||
it('pgvector extension is registered (pg_extension row exists)', async () => {
|
||||
sql = postgres(PG_URL, { max: 1, connect_timeout: 5, idle_timeout: 5 });
|
||||
const rows = await sql`SELECT * FROM pg_extension WHERE extname = 'vector'`;
|
||||
expect(rows).toHaveLength(1);
|
||||
}, 10_000);
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Test C — pgvector extension is functional end-to-end.
|
||||
*
|
||||
* Creates a temp table with a vector(3) column, inserts a row, and queries it
|
||||
* back — confirming the extension is not just registered but operational.
|
||||
*
|
||||
* Prereq: docker compose -f docker-compose.federated.yml --profile federated up -d
|
||||
* Run: FEDERATED_INTEGRATION=1 pnpm --filter @mosaicstack/gateway test src/__tests__/integration/federated-pgvector.integration.test.ts
|
||||
*
|
||||
* Skipped when FEDERATED_INTEGRATION !== '1'.
|
||||
*/
|
||||
|
||||
import postgres from 'postgres';
|
||||
import { afterAll, describe, expect, it } from 'vitest';
|
||||
|
||||
const run = process.env['FEDERATED_INTEGRATION'] === '1';
|
||||
|
||||
const PG_URL = 'postgresql://mosaic:mosaic@localhost:5433/mosaic';
|
||||
|
||||
let sql: ReturnType<typeof postgres> | undefined;
|
||||
|
||||
afterAll(async () => {
|
||||
if (sql) {
|
||||
await sql.end({ timeout: 2 }).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
describe.skipIf(!run)('federated pgvector — functional end-to-end', () => {
|
||||
it('vector ops round-trip: INSERT [1,2,3] and SELECT returns [1,2,3]', async () => {
|
||||
sql = postgres(PG_URL, { max: 1, connect_timeout: 5, idle_timeout: 5 });
|
||||
|
||||
await sql`CREATE TEMP TABLE t (id int, embedding vector(3))`;
|
||||
await sql`INSERT INTO t VALUES (1, '[1,2,3]')`;
|
||||
const rows = await sql`SELECT embedding FROM t`;
|
||||
|
||||
expect(rows).toHaveLength(1);
|
||||
// The postgres driver returns vector columns as strings like '[1,2,3]'.
|
||||
// Normalise by parsing the string representation.
|
||||
const raw = rows[0]?.['embedding'] as string;
|
||||
const parsed = JSON.parse(raw) as number[];
|
||||
expect(parsed).toEqual([1, 2, 3]);
|
||||
}, 10_000);
|
||||
});
|
||||
@@ -0,0 +1,243 @@
|
||||
/**
|
||||
* Federation M2 E2E test — peer-add enrollment flow (FED-M2-10).
|
||||
*
|
||||
* Covers MILESTONES.md acceptance test #6:
|
||||
* "`peer add <url>` on Server A yields an `active` peer record with a valid cert + key"
|
||||
*
|
||||
* This test simulates two gateways using a single bootstrapped NestJS app:
|
||||
* - "Server A": the admin API that generates a keypair and stores the cert
|
||||
* - "Server B": the enrollment endpoint that signs the CSR
|
||||
* Both share the same DB + Step-CA in the test environment.
|
||||
*
|
||||
* Prerequisites:
|
||||
* docker compose -f docker-compose.federated.yml --profile federated up -d
|
||||
*
|
||||
* Run:
|
||||
* FEDERATED_INTEGRATION=1 STEP_CA_AVAILABLE=1 \
|
||||
* STEP_CA_URL=https://localhost:9000 \
|
||||
* STEP_CA_PROVISIONER_KEY_JSON="$(docker exec $(docker ps -qf name=step-ca) cat /home/step/secrets/mosaic-fed.json)" \
|
||||
* STEP_CA_ROOT_CERT_PATH=/tmp/step-ca-root.crt \
|
||||
* pnpm --filter @mosaicstack/gateway test \
|
||||
* src/__tests__/integration/federation-m2-e2e.integration.test.ts
|
||||
*
|
||||
* Obtaining Step-CA credentials:
|
||||
* # Extract provisioner key from running container:
|
||||
* # docker exec $(docker ps -qf name=step-ca) cat /home/step/secrets/mosaic-fed.json
|
||||
* # Copy root cert from container:
|
||||
* # docker cp $(docker ps -qf name=step-ca):/home/step/certs/root_ca.crt /tmp/step-ca-root.crt
|
||||
* # Then: export STEP_CA_ROOT_CERT_PATH=/tmp/step-ca-root.crt
|
||||
*
|
||||
* Skipped unless both FEDERATED_INTEGRATION=1 and STEP_CA_AVAILABLE=1 are set.
|
||||
*/
|
||||
|
||||
import * as crypto from 'node:crypto';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
|
||||
import supertest from 'supertest';
|
||||
import {
|
||||
createDb,
|
||||
type Db,
|
||||
type DbHandle,
|
||||
federationPeers,
|
||||
federationGrants,
|
||||
federationEnrollmentTokens,
|
||||
inArray,
|
||||
eq,
|
||||
} from '@mosaicstack/db';
|
||||
import * as schema from '@mosaicstack/db';
|
||||
import { DB } from '../../database/database.module.js';
|
||||
import { AdminGuard } from '../../admin/admin.guard.js';
|
||||
import { FederationModule } from '../../federation/federation.module.js';
|
||||
import { GrantsService } from '../../federation/grants.service.js';
|
||||
import { EnrollmentService } from '../../federation/enrollment.service.js';
|
||||
|
||||
const run = process.env['FEDERATED_INTEGRATION'] === '1';
|
||||
const stepCaRun =
|
||||
run &&
|
||||
process.env['STEP_CA_AVAILABLE'] === '1' &&
|
||||
!!process.env['STEP_CA_URL'] &&
|
||||
!!process.env['STEP_CA_PROVISIONER_KEY_JSON'] &&
|
||||
!!process.env['STEP_CA_ROOT_CERT_PATH'];
|
||||
|
||||
const PG_URL = 'postgresql://mosaic:mosaic@localhost:5433/mosaic';
|
||||
|
||||
const RUN_ID = crypto.randomUUID();
|
||||
|
||||
describe.skipIf(!stepCaRun)('federation M2 E2E — peer add enrollment flow', () => {
|
||||
let handle: DbHandle;
|
||||
let db: Db;
|
||||
let app: NestFastifyApplication;
|
||||
let agent: ReturnType<typeof supertest>;
|
||||
let grantsService: GrantsService;
|
||||
let enrollmentService: EnrollmentService;
|
||||
|
||||
const createdTokenGrantIds: string[] = [];
|
||||
const createdGrantIds: string[] = [];
|
||||
const createdPeerIds: string[] = [];
|
||||
const createdUserIds: string[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
process.env['BETTER_AUTH_SECRET'] ??= 'test-e2e-sealing-key';
|
||||
|
||||
handle = createDb(PG_URL);
|
||||
db = handle.db;
|
||||
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
imports: [FederationModule],
|
||||
providers: [{ provide: DB, useValue: db }],
|
||||
})
|
||||
.overrideGuard(AdminGuard)
|
||||
.useValue({ canActivate: () => true })
|
||||
.compile();
|
||||
|
||||
app = moduleRef.createNestApplication<NestFastifyApplication>(new FastifyAdapter());
|
||||
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
|
||||
await app.init();
|
||||
await app.getHttpAdapter().getInstance().ready();
|
||||
|
||||
agent = supertest(app.getHttpServer());
|
||||
|
||||
grantsService = moduleRef.get(GrantsService);
|
||||
enrollmentService = moduleRef.get(EnrollmentService);
|
||||
}, 30_000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (db && createdTokenGrantIds.length > 0) {
|
||||
await db
|
||||
.delete(federationEnrollmentTokens)
|
||||
.where(inArray(federationEnrollmentTokens.grantId, createdTokenGrantIds))
|
||||
.catch((e: unknown) => console.error('[federation-m2-e2e cleanup]', e));
|
||||
}
|
||||
if (db && createdGrantIds.length > 0) {
|
||||
await db
|
||||
.delete(federationGrants)
|
||||
.where(inArray(federationGrants.id, createdGrantIds))
|
||||
.catch((e: unknown) => console.error('[federation-m2-e2e cleanup]', e));
|
||||
}
|
||||
if (db && createdPeerIds.length > 0) {
|
||||
await db
|
||||
.delete(federationPeers)
|
||||
.where(inArray(federationPeers.id, createdPeerIds))
|
||||
.catch((e: unknown) => console.error('[federation-m2-e2e cleanup]', e));
|
||||
}
|
||||
if (db && createdUserIds.length > 0) {
|
||||
await db
|
||||
.delete(schema.users)
|
||||
.where(inArray(schema.users.id, createdUserIds))
|
||||
.catch((e: unknown) => console.error('[federation-m2-e2e cleanup]', e));
|
||||
}
|
||||
if (app)
|
||||
await app.close().catch((e: unknown) => console.error('[federation-m2-e2e cleanup]', e));
|
||||
if (handle)
|
||||
await handle.close().catch((e: unknown) => console.error('[federation-m2-e2e cleanup]', e));
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// #6 — peer add: keypair → enrollment → cert storage → active peer record
|
||||
// -------------------------------------------------------------------------
|
||||
it('#6 — peer add flow: keypair → enrollment → cert storage → active peer record', async () => {
|
||||
// Create a subject user to satisfy FK on federation_grants.subject_user_id
|
||||
const userId = crypto.randomUUID();
|
||||
await db
|
||||
.insert(schema.users)
|
||||
.values({
|
||||
id: userId,
|
||||
name: `e2e-user-${RUN_ID}`,
|
||||
email: `e2e-${RUN_ID}@federation-test.invalid`,
|
||||
emailVerified: false,
|
||||
})
|
||||
.onConflictDoNothing();
|
||||
createdUserIds.push(userId);
|
||||
|
||||
// ── Step A: "Server B" setup ─────────────────────────────────────────
|
||||
// Server B admin creates a grant and generates an enrollment token to
|
||||
// share out-of-band with Server A's operator.
|
||||
|
||||
// Insert a placeholder peer on "Server B" to satisfy the grant FK
|
||||
const serverBPeerId = crypto.randomUUID();
|
||||
await db
|
||||
.insert(federationPeers)
|
||||
.values({
|
||||
id: serverBPeerId,
|
||||
commonName: `server-b-peer-${RUN_ID}`,
|
||||
displayName: 'Server B Placeholder',
|
||||
certPem: '-----BEGIN CERTIFICATE-----\nMOCK\n-----END CERTIFICATE-----\n',
|
||||
certSerial: `serial-b-${serverBPeerId}`,
|
||||
certNotAfter: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000),
|
||||
state: 'pending',
|
||||
})
|
||||
.onConflictDoNothing();
|
||||
createdPeerIds.push(serverBPeerId);
|
||||
|
||||
const grant = await grantsService.createGrant({
|
||||
subjectUserId: userId,
|
||||
scope: { resources: ['tasks'], excluded_resources: [], max_rows_per_query: 100 },
|
||||
peerId: serverBPeerId,
|
||||
});
|
||||
createdGrantIds.push(grant.id);
|
||||
createdTokenGrantIds.push(grant.id);
|
||||
|
||||
const { token } = await enrollmentService.createToken({
|
||||
grantId: grant.id,
|
||||
peerId: serverBPeerId,
|
||||
ttlSeconds: 900,
|
||||
});
|
||||
|
||||
// ── Step B: "Server A" generates keypair ─────────────────────────────
|
||||
const keypairRes = await agent
|
||||
.post('/api/admin/federation/peers/keypair')
|
||||
.send({
|
||||
commonName: `e2e-peer-${RUN_ID.slice(0, 8)}`,
|
||||
displayName: 'E2E Test Peer',
|
||||
endpointUrl: 'https://test.invalid',
|
||||
})
|
||||
.set('Content-Type', 'application/json');
|
||||
|
||||
expect(keypairRes.status).toBe(201);
|
||||
const { peerId, csrPem } = keypairRes.body as { peerId: string; csrPem: string };
|
||||
expect(typeof peerId).toBe('string');
|
||||
expect(csrPem).toContain('-----BEGIN CERTIFICATE REQUEST-----');
|
||||
createdPeerIds.push(peerId);
|
||||
|
||||
// ── Step C: Enrollment (simulates Server A sending CSR to Server B) ──
|
||||
const enrollRes = await agent
|
||||
.post(`/api/federation/enrollment/${token}`)
|
||||
.send({ csrPem })
|
||||
.set('Content-Type', 'application/json');
|
||||
|
||||
expect(enrollRes.status).toBe(200);
|
||||
const { certPem, certChainPem } = enrollRes.body as {
|
||||
certPem: string;
|
||||
certChainPem: string;
|
||||
};
|
||||
expect(certPem).toContain('-----BEGIN CERTIFICATE-----');
|
||||
expect(certChainPem).toContain('-----BEGIN CERTIFICATE-----');
|
||||
|
||||
// ── Step D: "Server A" stores the cert ───────────────────────────────
|
||||
const storeRes = await agent
|
||||
.patch(`/api/admin/federation/peers/${peerId}/cert`)
|
||||
.send({ certPem })
|
||||
.set('Content-Type', 'application/json');
|
||||
|
||||
expect(storeRes.status).toBe(200);
|
||||
|
||||
// ── Step E: Verify peer record in DB ─────────────────────────────────
|
||||
const [peer] = await db
|
||||
.select()
|
||||
.from(federationPeers)
|
||||
.where(eq(federationPeers.id, peerId))
|
||||
.limit(1);
|
||||
|
||||
expect(peer).toBeDefined();
|
||||
expect(peer?.state).toBe('active');
|
||||
expect(peer?.certPem).toContain('-----BEGIN CERTIFICATE-----');
|
||||
expect(typeof peer?.certSerial).toBe('string');
|
||||
expect((peer?.certSerial ?? '').length).toBeGreaterThan(0);
|
||||
// clientKeyPem is a sealed ciphertext — must not be a raw PEM
|
||||
expect(peer?.clientKeyPem?.startsWith('-----BEGIN')).toBe(false);
|
||||
// certNotAfter must be in the future
|
||||
expect(peer?.certNotAfter?.getTime()).toBeGreaterThan(Date.now());
|
||||
}, 60_000);
|
||||
});
|
||||
@@ -0,0 +1,483 @@
|
||||
/**
|
||||
* Federation M2 integration tests (FED-M2-09).
|
||||
*
|
||||
* Covers MILESTONES.md acceptance tests #1, #2, #3, #5, #7, #8.
|
||||
*
|
||||
* Prerequisites:
|
||||
* docker compose -f docker-compose.federated.yml --profile federated up -d
|
||||
*
|
||||
* Run DB-only tests (no Step-CA):
|
||||
* FEDERATED_INTEGRATION=1 BETTER_AUTH_SECRET=test-secret pnpm --filter @mosaicstack/gateway test \
|
||||
* src/__tests__/integration/federation-m2.integration.test.ts
|
||||
*
|
||||
* Run all tests including Step-CA-dependent ones:
|
||||
* FEDERATED_INTEGRATION=1 STEP_CA_AVAILABLE=1 \
|
||||
* STEP_CA_URL=https://localhost:9000 \
|
||||
* STEP_CA_PROVISIONER_KEY_JSON="$(docker exec $(docker ps -qf name=step-ca) cat /home/step/secrets/mosaic-fed.json)" \
|
||||
* STEP_CA_ROOT_CERT_PATH=/tmp/step-ca-root.crt \
|
||||
* pnpm --filter @mosaicstack/gateway test \
|
||||
* src/__tests__/integration/federation-m2.integration.test.ts
|
||||
*
|
||||
* Obtaining Step-CA credentials:
|
||||
* # Extract provisioner key from running container:
|
||||
* # docker exec $(docker ps -qf name=step-ca) cat /home/step/secrets/mosaic-fed.json
|
||||
* # Copy root cert from container:
|
||||
* # docker cp $(docker ps -qf name=step-ca):/home/step/certs/root_ca.crt /tmp/step-ca-root.crt
|
||||
* # Then: export STEP_CA_ROOT_CERT_PATH=/tmp/step-ca-root.crt
|
||||
*/
|
||||
|
||||
import * as crypto from 'node:crypto';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { GoneException } from '@nestjs/common';
|
||||
import { Pkcs10CertificateRequestGenerator, X509Certificate as PeculiarX509 } from '@peculiar/x509';
|
||||
import {
|
||||
createDb,
|
||||
type Db,
|
||||
type DbHandle,
|
||||
federationPeers,
|
||||
federationGrants,
|
||||
federationEnrollmentTokens,
|
||||
inArray,
|
||||
eq,
|
||||
} from '@mosaicstack/db';
|
||||
import * as schema from '@mosaicstack/db';
|
||||
import { seal } from '@mosaicstack/auth';
|
||||
import { DB } from '../../database/database.module.js';
|
||||
import { GrantsService } from '../../federation/grants.service.js';
|
||||
import { EnrollmentService } from '../../federation/enrollment.service.js';
|
||||
import { CaService } from '../../federation/ca.service.js';
|
||||
import { FederationScopeError } from '../../federation/scope-schema.js';
|
||||
|
||||
const run = process.env['FEDERATED_INTEGRATION'] === '1';
|
||||
const stepCaRun = run && process.env['STEP_CA_AVAILABLE'] === '1';
|
||||
|
||||
const PG_URL = 'postgresql://mosaic:mosaic@localhost:5433/mosaic';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers for test data isolation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Unique run prefix to identify rows created by this test run. */
|
||||
const RUN_ID = crypto.randomUUID();
|
||||
|
||||
/** Insert a minimal user row to satisfy the FK on federation_grants.subject_user_id. */
|
||||
async function insertTestUser(db: Db, id: string): Promise<void> {
|
||||
await db
|
||||
.insert(schema.users)
|
||||
.values({
|
||||
id,
|
||||
name: `test-user-${id}`,
|
||||
email: `test-${id}@federation-test.invalid`,
|
||||
emailVerified: false,
|
||||
})
|
||||
.onConflictDoNothing();
|
||||
}
|
||||
|
||||
/** Insert a minimal peer row to satisfy the FK on federation_grants.peer_id. */
|
||||
async function insertTestPeer(db: Db, id: string, suffix: string = ''): Promise<void> {
|
||||
await db
|
||||
.insert(federationPeers)
|
||||
.values({
|
||||
id,
|
||||
commonName: `test-peer-${RUN_ID}-${suffix}`,
|
||||
displayName: `Test Peer ${suffix}`,
|
||||
certPem: '-----BEGIN CERTIFICATE-----\nMOCK\n-----END CERTIFICATE-----\n',
|
||||
certSerial: `test-serial-${id}`,
|
||||
certNotAfter: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000),
|
||||
state: 'pending',
|
||||
})
|
||||
.onConflictDoNothing();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DB-only test module (CaService mocked so env vars not required)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function buildDbModule(db: Db) {
|
||||
return Test.createTestingModule({
|
||||
providers: [
|
||||
{ provide: DB, useValue: db },
|
||||
GrantsService,
|
||||
{
|
||||
provide: CaService,
|
||||
useValue: {
|
||||
issueCert: async () => {
|
||||
throw new Error('CaService.issueCert should not be called in DB-only tests');
|
||||
},
|
||||
},
|
||||
},
|
||||
EnrollmentService,
|
||||
],
|
||||
}).compile();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test suite — DB-only (no Step-CA)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe.skipIf(!run)('federation M2 — DB-only tests', () => {
|
||||
let handle: DbHandle;
|
||||
let db: Db;
|
||||
let grantsService: GrantsService;
|
||||
|
||||
/** IDs created during this run — cleaned up in afterAll. */
|
||||
const createdGrantIds: string[] = [];
|
||||
const createdPeerIds: string[] = [];
|
||||
const createdUserIds: string[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
process.env['BETTER_AUTH_SECRET'] ??= 'test-integration-sealing-key-not-for-prod';
|
||||
|
||||
handle = createDb(PG_URL);
|
||||
db = handle.db;
|
||||
|
||||
const moduleRef = await buildDbModule(db);
|
||||
grantsService = moduleRef.get(GrantsService);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// Clean up in FK-safe order: tokens → grants → peers → users
|
||||
if (db && createdGrantIds.length > 0) {
|
||||
await db
|
||||
.delete(federationEnrollmentTokens)
|
||||
.where(inArray(federationEnrollmentTokens.grantId, createdGrantIds))
|
||||
.catch((e: unknown) => console.error('[federation-m2-test cleanup]', e));
|
||||
await db
|
||||
.delete(federationGrants)
|
||||
.where(inArray(federationGrants.id, createdGrantIds))
|
||||
.catch((e: unknown) => console.error('[federation-m2-test cleanup]', e));
|
||||
}
|
||||
if (db && createdPeerIds.length > 0) {
|
||||
await db
|
||||
.delete(federationPeers)
|
||||
.where(inArray(federationPeers.id, createdPeerIds))
|
||||
.catch((e: unknown) => console.error('[federation-m2-test cleanup]', e));
|
||||
}
|
||||
if (db && createdUserIds.length > 0) {
|
||||
await db
|
||||
.delete(schema.users)
|
||||
.where(inArray(schema.users.id, createdUserIds))
|
||||
.catch((e: unknown) => console.error('[federation-m2-test cleanup]', e));
|
||||
}
|
||||
if (handle)
|
||||
await handle.close().catch((e: unknown) => console.error('[federation-m2-test cleanup]', e));
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// #1 — grant create writes a pending row
|
||||
// -------------------------------------------------------------------------
|
||||
it('#1 — createGrant writes a pending row to DB', async () => {
|
||||
const userId = crypto.randomUUID();
|
||||
const peerId = crypto.randomUUID();
|
||||
const validScope = {
|
||||
resources: ['tasks'],
|
||||
excluded_resources: [],
|
||||
max_rows_per_query: 100,
|
||||
};
|
||||
|
||||
await insertTestUser(db, userId);
|
||||
await insertTestPeer(db, peerId, 'test1');
|
||||
createdUserIds.push(userId);
|
||||
createdPeerIds.push(peerId);
|
||||
|
||||
const grant = await grantsService.createGrant({
|
||||
subjectUserId: userId,
|
||||
scope: validScope,
|
||||
peerId,
|
||||
});
|
||||
|
||||
createdGrantIds.push(grant.id);
|
||||
|
||||
// Verify the row exists in DB with correct shape
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(federationGrants)
|
||||
.where(eq(federationGrants.id, grant.id))
|
||||
.limit(1);
|
||||
|
||||
expect(row).toBeDefined();
|
||||
expect(row?.status).toBe('pending');
|
||||
expect(row?.peerId).toBe(peerId);
|
||||
expect(row?.subjectUserId).toBe(userId);
|
||||
const storedScope = row?.scope as Record<string, unknown>;
|
||||
expect(storedScope['resources']).toEqual(['tasks']);
|
||||
expect(storedScope['max_rows_per_query']).toBe(100);
|
||||
}, 15_000);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// #7 — scope with unknown resource type rejected
|
||||
// -------------------------------------------------------------------------
|
||||
it('#7 — createGrant rejects scope with unknown resource type', async () => {
|
||||
const userId = crypto.randomUUID();
|
||||
const peerId = crypto.randomUUID();
|
||||
const invalidScope = {
|
||||
resources: ['totally_unknown_resource'],
|
||||
excluded_resources: [],
|
||||
max_rows_per_query: 100,
|
||||
};
|
||||
|
||||
await insertTestUser(db, userId);
|
||||
await insertTestPeer(db, peerId, 'test7');
|
||||
createdUserIds.push(userId);
|
||||
createdPeerIds.push(peerId);
|
||||
|
||||
await expect(
|
||||
grantsService.createGrant({
|
||||
subjectUserId: userId,
|
||||
scope: invalidScope,
|
||||
peerId,
|
||||
}),
|
||||
).rejects.toThrow(FederationScopeError);
|
||||
}, 15_000);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// #8 — listGrants returns accurate status for grants in various states
|
||||
// -------------------------------------------------------------------------
|
||||
it('#8 — listGrants returns accurate status for grants in various states', async () => {
|
||||
const userId = crypto.randomUUID();
|
||||
const peerId = crypto.randomUUID();
|
||||
const validScope = {
|
||||
resources: ['notes'],
|
||||
excluded_resources: [],
|
||||
max_rows_per_query: 50,
|
||||
};
|
||||
|
||||
await insertTestUser(db, userId);
|
||||
await insertTestPeer(db, peerId, 'test8');
|
||||
createdUserIds.push(userId);
|
||||
createdPeerIds.push(peerId);
|
||||
|
||||
// Create two pending grants via GrantsService
|
||||
const grantA = await grantsService.createGrant({
|
||||
subjectUserId: userId,
|
||||
scope: validScope,
|
||||
peerId,
|
||||
});
|
||||
const grantB = await grantsService.createGrant({
|
||||
subjectUserId: userId,
|
||||
scope: { resources: ['tasks'], excluded_resources: [], max_rows_per_query: 50 },
|
||||
peerId,
|
||||
});
|
||||
createdGrantIds.push(grantA.id, grantB.id);
|
||||
|
||||
// Insert a third grant directly in 'revoked' state to test status variety
|
||||
const [grantC] = await db
|
||||
.insert(federationGrants)
|
||||
.values({
|
||||
id: crypto.randomUUID(),
|
||||
subjectUserId: userId,
|
||||
peerId,
|
||||
scope: validScope,
|
||||
status: 'revoked',
|
||||
revokedAt: new Date(),
|
||||
})
|
||||
.returning();
|
||||
createdGrantIds.push(grantC!.id);
|
||||
|
||||
// List all grants for this peer
|
||||
const allForPeer = await grantsService.listGrants({ peerId });
|
||||
|
||||
const ourGrantIds = new Set([grantA.id, grantB.id, grantC!.id]);
|
||||
const ourGrants = allForPeer.filter((g) => ourGrantIds.has(g.id));
|
||||
expect(ourGrants).toHaveLength(3);
|
||||
|
||||
const pendingGrants = ourGrants.filter((g) => g.status === 'pending');
|
||||
const revokedGrants = ourGrants.filter((g) => g.status === 'revoked');
|
||||
expect(pendingGrants).toHaveLength(2);
|
||||
expect(revokedGrants).toHaveLength(1);
|
||||
|
||||
// Status-filtered query
|
||||
const pendingOnly = await grantsService.listGrants({ peerId, status: 'pending' });
|
||||
const ourPending = pendingOnly.filter((g) => ourGrantIds.has(g.id));
|
||||
expect(ourPending.every((g) => g.status === 'pending')).toBe(true);
|
||||
|
||||
// Verify peer list from DB also shows the peer rows with correct state
|
||||
const peers = await db.select().from(federationPeers).where(eq(federationPeers.id, peerId));
|
||||
expect(peers).toHaveLength(1);
|
||||
expect(peers[0]?.state).toBe('pending');
|
||||
}, 15_000);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// #5 — client_key_pem encrypted at rest
|
||||
// -------------------------------------------------------------------------
|
||||
it('#5 — clientKeyPem stored in DB is a sealed ciphertext (not a valid PEM)', async () => {
|
||||
const peerId = crypto.randomUUID();
|
||||
const rawPem = '-----BEGIN PRIVATE KEY-----\nMOCK\n-----END PRIVATE KEY-----\n';
|
||||
const sealed = seal(rawPem);
|
||||
|
||||
await db.insert(federationPeers).values({
|
||||
id: peerId,
|
||||
commonName: `test-peer-${RUN_ID}-sealed`,
|
||||
displayName: 'Sealed Key Test Peer',
|
||||
certPem: '-----BEGIN CERTIFICATE-----\nMOCK\n-----END CERTIFICATE-----\n',
|
||||
certSerial: `test-serial-sealed-${peerId}`,
|
||||
certNotAfter: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000),
|
||||
state: 'pending',
|
||||
clientKeyPem: sealed,
|
||||
});
|
||||
createdPeerIds.push(peerId);
|
||||
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(federationPeers)
|
||||
.where(eq(federationPeers.id, peerId))
|
||||
.limit(1);
|
||||
|
||||
expect(row).toBeDefined();
|
||||
// The stored value must NOT be a valid PEM — it's a sealed ciphertext blob
|
||||
expect(row?.clientKeyPem).toBeDefined();
|
||||
expect(row?.clientKeyPem?.startsWith('-----BEGIN')).toBe(false);
|
||||
// The sealed value should be non-trivial (at least 20 chars)
|
||||
expect((row?.clientKeyPem ?? '').length).toBeGreaterThan(20);
|
||||
}, 15_000);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test suite — Step-CA gated
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe.skipIf(!stepCaRun)('federation M2 — Step-CA tests', () => {
|
||||
let handle: DbHandle;
|
||||
let db: Db;
|
||||
let grantsService: GrantsService;
|
||||
let enrollmentService: EnrollmentService;
|
||||
|
||||
const createdGrantIds: string[] = [];
|
||||
const createdPeerIds: string[] = [];
|
||||
const createdUserIds: string[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
handle = createDb(PG_URL);
|
||||
db = handle.db;
|
||||
|
||||
// Use real CaService — env vars (STEP_CA_URL, STEP_CA_PROVISIONER_KEY_JSON,
|
||||
// STEP_CA_ROOT_CERT_PATH) must be set when STEP_CA_AVAILABLE=1
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
providers: [{ provide: DB, useValue: db }, CaService, GrantsService, EnrollmentService],
|
||||
}).compile();
|
||||
|
||||
grantsService = moduleRef.get(GrantsService);
|
||||
enrollmentService = moduleRef.get(EnrollmentService);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (db && createdGrantIds.length > 0) {
|
||||
await db
|
||||
.delete(federationEnrollmentTokens)
|
||||
.where(inArray(federationEnrollmentTokens.grantId, createdGrantIds))
|
||||
.catch((e: unknown) => console.error('[federation-m2-test cleanup]', e));
|
||||
await db
|
||||
.delete(federationGrants)
|
||||
.where(inArray(federationGrants.id, createdGrantIds))
|
||||
.catch((e: unknown) => console.error('[federation-m2-test cleanup]', e));
|
||||
}
|
||||
if (db && createdPeerIds.length > 0) {
|
||||
await db
|
||||
.delete(federationPeers)
|
||||
.where(inArray(federationPeers.id, createdPeerIds))
|
||||
.catch((e: unknown) => console.error('[federation-m2-test cleanup]', e));
|
||||
}
|
||||
if (db && createdUserIds.length > 0) {
|
||||
await db
|
||||
.delete(schema.users)
|
||||
.where(inArray(schema.users.id, createdUserIds))
|
||||
.catch((e: unknown) => console.error('[federation-m2-test cleanup]', e));
|
||||
}
|
||||
if (handle)
|
||||
await handle.close().catch((e: unknown) => console.error('[federation-m2-test cleanup]', e));
|
||||
});
|
||||
|
||||
/** Generate a P-256 key pair and PKCS#10 CSR, returning the CSR as PEM. */
|
||||
async function generateCsrPem(cn: string): Promise<string> {
|
||||
const alg = { name: 'ECDSA', namedCurve: 'P-256', hash: 'SHA-256' };
|
||||
const keyPair = await crypto.subtle.generateKey(alg, true, ['sign', 'verify']);
|
||||
const csr = await Pkcs10CertificateRequestGenerator.create({
|
||||
name: `CN=${cn}`,
|
||||
keys: keyPair,
|
||||
signingAlgorithm: alg,
|
||||
});
|
||||
return csr.toString('pem');
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// #2 — enrollment signs CSR and returns cert
|
||||
// -------------------------------------------------------------------------
|
||||
it('#2 — redeem returns a certPem containing a valid PEM certificate', async () => {
|
||||
const userId = crypto.randomUUID();
|
||||
const peerId = crypto.randomUUID();
|
||||
const validScope = {
|
||||
resources: ['tasks'],
|
||||
excluded_resources: [],
|
||||
max_rows_per_query: 100,
|
||||
};
|
||||
|
||||
await insertTestUser(db, userId);
|
||||
await insertTestPeer(db, peerId, 'ca-test2');
|
||||
createdUserIds.push(userId);
|
||||
createdPeerIds.push(peerId);
|
||||
|
||||
const grant = await grantsService.createGrant({
|
||||
subjectUserId: userId,
|
||||
scope: validScope,
|
||||
peerId,
|
||||
});
|
||||
createdGrantIds.push(grant.id);
|
||||
|
||||
const { token } = await enrollmentService.createToken({
|
||||
grantId: grant.id,
|
||||
peerId,
|
||||
ttlSeconds: 900,
|
||||
});
|
||||
|
||||
const csrPem = await generateCsrPem(`gateway-test-${RUN_ID.slice(0, 8)}`);
|
||||
const result = await enrollmentService.redeem(token, csrPem);
|
||||
|
||||
expect(result.certPem).toContain('-----BEGIN CERTIFICATE-----');
|
||||
expect(result.certChainPem).toContain('-----BEGIN CERTIFICATE-----');
|
||||
|
||||
// Verify the issued cert parses cleanly
|
||||
const cert = new PeculiarX509(result.certPem);
|
||||
expect(cert.serialNumber).toBeTruthy();
|
||||
}, 30_000);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// #3 — token single-use; second attempt returns GoneException
|
||||
// -------------------------------------------------------------------------
|
||||
it('#3 — second redeem of the same token throws GoneException', async () => {
|
||||
const userId = crypto.randomUUID();
|
||||
const peerId = crypto.randomUUID();
|
||||
const validScope = {
|
||||
resources: ['notes'],
|
||||
excluded_resources: [],
|
||||
max_rows_per_query: 50,
|
||||
};
|
||||
|
||||
await insertTestUser(db, userId);
|
||||
await insertTestPeer(db, peerId, 'ca-test3');
|
||||
createdUserIds.push(userId);
|
||||
createdPeerIds.push(peerId);
|
||||
|
||||
const grant = await grantsService.createGrant({
|
||||
subjectUserId: userId,
|
||||
scope: validScope,
|
||||
peerId,
|
||||
});
|
||||
createdGrantIds.push(grant.id);
|
||||
|
||||
const { token } = await enrollmentService.createToken({
|
||||
grantId: grant.id,
|
||||
peerId,
|
||||
ttlSeconds: 900,
|
||||
});
|
||||
|
||||
const csrPem = await generateCsrPem(`gateway-test-replay-${RUN_ID.slice(0, 8)}`);
|
||||
|
||||
// First redeem must succeed
|
||||
const result = await enrollmentService.redeem(token, csrPem);
|
||||
expect(result.certPem).toContain('-----BEGIN CERTIFICATE-----');
|
||||
|
||||
// Second redeem with the same token must be rejected
|
||||
await expect(enrollmentService.redeem(token, csrPem)).rejects.toThrow(GoneException);
|
||||
}, 30_000);
|
||||
});
|
||||
@@ -0,0 +1,519 @@
|
||||
/**
|
||||
* Federation M3 single-gateway integration tests (FED-M3-10).
|
||||
*
|
||||
* Covers MILESTONES.md M3 acceptance:
|
||||
* - #6: malformed certificate OIDs fail with 401; valid cert + revoked grant fails with 403.
|
||||
* - #7: max_rows_per_query caps list results.
|
||||
*
|
||||
* Strategy:
|
||||
* - Real PostgreSQL via @mosaicstack/db.
|
||||
* - Mocked TLS context/Fastify request shim for FederationAuthGuard.
|
||||
* - Direct controller calls using the real POST /api/federation/v1/list/:resource contract.
|
||||
*
|
||||
* Run:
|
||||
* FEDERATED_INTEGRATION=1 pnpm --filter @mosaicstack/gateway test -- \
|
||||
* src/__tests__/integration/federation-m3-list.integration.test.ts
|
||||
*/
|
||||
|
||||
import 'reflect-metadata';
|
||||
import * as crypto from 'node:crypto';
|
||||
import type { ExecutionContext } from '@nestjs/common';
|
||||
import { Test, type TestingModule } from '@nestjs/testing';
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import {
|
||||
and,
|
||||
createDb,
|
||||
eq,
|
||||
federationGrants,
|
||||
federationPeers,
|
||||
inArray,
|
||||
missionTasks,
|
||||
missions,
|
||||
projects,
|
||||
tasks,
|
||||
teamMembers,
|
||||
teams,
|
||||
type Db,
|
||||
type DbHandle,
|
||||
users,
|
||||
} from '@mosaicstack/db';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { DB } from '../../database/database.module.js';
|
||||
import { GrantsService } from '../../federation/grants.service.js';
|
||||
import { FederationAuthGuard } from '../../federation/server/federation-auth.guard.js';
|
||||
import { FederationScopeService } from '../../federation/server/scope.service.js';
|
||||
import { FederationListQueryService } from '../../federation/server/verbs/list-query.service.js';
|
||||
import { ListController } from '../../federation/server/verbs/list.controller.js';
|
||||
import {
|
||||
makeMosaicIssuedCert,
|
||||
makeSelfSignedCert,
|
||||
} from '../../federation/__tests__/helpers/test-cert.js';
|
||||
|
||||
const run = process.env['FEDERATED_INTEGRATION'] === '1';
|
||||
const PG_URL = process.env['DATABASE_URL'] ?? 'postgresql://mosaic:mosaic@localhost:5433/mosaic';
|
||||
const RUN_ID = `fed-m3-10-${crypto.randomUUID()}`;
|
||||
const CERT_SERIAL_HEX = crypto.randomUUID().replace(/-/g, '').toUpperCase();
|
||||
|
||||
interface TestIds {
|
||||
readonly subjectUserId: string;
|
||||
readonly otherUserId: string;
|
||||
readonly peerId: string;
|
||||
readonly revokedPeerId: string;
|
||||
readonly activeGrantId: string;
|
||||
readonly revokedGrantId: string;
|
||||
readonly subjectProjectId: string;
|
||||
readonly subjectMissionId: string;
|
||||
readonly otherProjectId: string;
|
||||
readonly teamId: string;
|
||||
readonly unauthorizedTeamId: string;
|
||||
readonly teamProjectId: string;
|
||||
readonly taskIds: readonly string[];
|
||||
readonly excludedTaskIds: readonly string[];
|
||||
readonly subjectNoteId: string;
|
||||
readonly otherUserNoteId: string;
|
||||
}
|
||||
|
||||
function pemToDer(pem: string): Buffer {
|
||||
return Buffer.from(
|
||||
pem
|
||||
.replace(/-----BEGIN CERTIFICATE-----/, '')
|
||||
.replace(/-----END CERTIFICATE-----/, '')
|
||||
.replace(/\s+/g, ''),
|
||||
'base64',
|
||||
);
|
||||
}
|
||||
|
||||
function makeFederationRequest(certPem: string): FastifyRequest {
|
||||
return {
|
||||
raw: {
|
||||
socket: {
|
||||
getPeerCertificate: () => ({
|
||||
raw: pemToDer(certPem),
|
||||
serialNumber: CERT_SERIAL_HEX,
|
||||
}),
|
||||
},
|
||||
},
|
||||
} as unknown as FastifyRequest;
|
||||
}
|
||||
|
||||
function makeGuardContext(request: FastifyRequest): {
|
||||
readonly context: ExecutionContext;
|
||||
readonly sent: { statusCode?: number; payload?: unknown };
|
||||
} {
|
||||
const sent: { statusCode?: number; payload?: unknown } = {};
|
||||
const reply = {
|
||||
status: (statusCode: number) => {
|
||||
sent.statusCode = statusCode;
|
||||
return {
|
||||
header: () => ({
|
||||
send: (payload: unknown) => {
|
||||
sent.payload = payload;
|
||||
},
|
||||
}),
|
||||
};
|
||||
},
|
||||
} as unknown as FastifyReply;
|
||||
|
||||
const context = {
|
||||
switchToHttp: () => ({
|
||||
getRequest: () => request,
|
||||
getResponse: () => reply,
|
||||
}),
|
||||
} as unknown as ExecutionContext;
|
||||
|
||||
return { context, sent };
|
||||
}
|
||||
|
||||
async function insertUser(db: Db, id: string, label: string): Promise<void> {
|
||||
await db.insert(users).values({
|
||||
id,
|
||||
name: `${RUN_ID}-${label}`,
|
||||
email: `${RUN_ID}-${label}@federation-test.invalid`,
|
||||
emailVerified: false,
|
||||
});
|
||||
}
|
||||
|
||||
async function seedFixtures(db: Db): Promise<TestIds> {
|
||||
const subjectUserId = `${RUN_ID}-subject`;
|
||||
const otherUserId = `${RUN_ID}-other`;
|
||||
const peerId = crypto.randomUUID();
|
||||
const revokedPeerId = crypto.randomUUID();
|
||||
const activeGrantId = crypto.randomUUID();
|
||||
const revokedGrantId = crypto.randomUUID();
|
||||
const subjectProjectId = crypto.randomUUID();
|
||||
const subjectMissionId = crypto.randomUUID();
|
||||
const otherProjectId = crypto.randomUUID();
|
||||
const teamId = crypto.randomUUID();
|
||||
const unauthorizedTeamId = crypto.randomUUID();
|
||||
const teamProjectId = crypto.randomUUID();
|
||||
const taskIds = [crypto.randomUUID(), crypto.randomUUID(), crypto.randomUUID()] as const;
|
||||
const excludedTaskIds = [crypto.randomUUID(), crypto.randomUUID()] as const;
|
||||
const subjectNoteId = crypto.randomUUID();
|
||||
const otherUserNoteId = crypto.randomUUID();
|
||||
|
||||
await insertUser(db, subjectUserId, 'subject');
|
||||
await insertUser(db, otherUserId, 'other');
|
||||
|
||||
await db.insert(teams).values([
|
||||
{
|
||||
id: teamId,
|
||||
name: `${RUN_ID} allowed team`,
|
||||
slug: `${RUN_ID}-allowed-team`,
|
||||
ownerId: subjectUserId,
|
||||
managerId: subjectUserId,
|
||||
},
|
||||
{
|
||||
id: unauthorizedTeamId,
|
||||
name: `${RUN_ID} unauthorized team`,
|
||||
slug: `${RUN_ID}-unauthorized-team`,
|
||||
ownerId: otherUserId,
|
||||
managerId: otherUserId,
|
||||
},
|
||||
]);
|
||||
|
||||
await db.insert(teamMembers).values([
|
||||
{ teamId, userId: subjectUserId, role: 'member' },
|
||||
{ teamId: unauthorizedTeamId, userId: subjectUserId, role: 'member' },
|
||||
]);
|
||||
|
||||
await db.insert(projects).values([
|
||||
{
|
||||
id: subjectProjectId,
|
||||
name: `${RUN_ID} subject personal project`,
|
||||
ownerType: 'user',
|
||||
ownerId: subjectUserId,
|
||||
},
|
||||
{
|
||||
id: otherProjectId,
|
||||
name: `${RUN_ID} other personal project`,
|
||||
ownerType: 'user',
|
||||
ownerId: otherUserId,
|
||||
},
|
||||
{
|
||||
id: teamProjectId,
|
||||
name: `${RUN_ID} unauthorized team project`,
|
||||
ownerType: 'team',
|
||||
teamId: unauthorizedTeamId,
|
||||
},
|
||||
]);
|
||||
|
||||
await db.insert(missions).values({
|
||||
id: subjectMissionId,
|
||||
name: `${RUN_ID} subject mission`,
|
||||
projectId: subjectProjectId,
|
||||
userId: subjectUserId,
|
||||
});
|
||||
|
||||
await db.insert(tasks).values([
|
||||
{
|
||||
id: taskIds[0],
|
||||
title: `${RUN_ID} visible task 1`,
|
||||
missionId: subjectMissionId,
|
||||
createdAt: new Date('2026-06-25T03:00:00.000Z'),
|
||||
updatedAt: new Date('2026-06-25T03:00:00.000Z'),
|
||||
},
|
||||
{
|
||||
id: taskIds[1],
|
||||
title: `${RUN_ID} visible task 2`,
|
||||
projectId: subjectProjectId,
|
||||
createdAt: new Date('2026-06-25T02:00:00.000Z'),
|
||||
updatedAt: new Date('2026-06-25T02:00:00.000Z'),
|
||||
},
|
||||
{
|
||||
id: taskIds[2],
|
||||
title: `${RUN_ID} visible task 3`,
|
||||
projectId: subjectProjectId,
|
||||
createdAt: new Date('2026-06-25T01:00:00.000Z'),
|
||||
updatedAt: new Date('2026-06-25T01:00:00.000Z'),
|
||||
},
|
||||
{
|
||||
id: excludedTaskIds[0],
|
||||
title: `${RUN_ID} other user task`,
|
||||
projectId: otherProjectId,
|
||||
createdAt: new Date('2026-06-25T04:00:00.000Z'),
|
||||
updatedAt: new Date('2026-06-25T04:00:00.000Z'),
|
||||
},
|
||||
{
|
||||
id: excludedTaskIds[1],
|
||||
title: `${RUN_ID} unauthorized team task`,
|
||||
projectId: teamProjectId,
|
||||
createdAt: new Date('2026-06-25T05:00:00.000Z'),
|
||||
updatedAt: new Date('2026-06-25T05:00:00.000Z'),
|
||||
},
|
||||
]);
|
||||
|
||||
await db.insert(missionTasks).values([
|
||||
{
|
||||
id: subjectNoteId,
|
||||
missionId: subjectMissionId,
|
||||
userId: subjectUserId,
|
||||
notes: `${RUN_ID} subject visible note`,
|
||||
createdAt: new Date('2026-06-25T03:30:00.000Z'),
|
||||
updatedAt: new Date('2026-06-25T03:30:00.000Z'),
|
||||
},
|
||||
{
|
||||
id: otherUserNoteId,
|
||||
missionId: subjectMissionId,
|
||||
userId: otherUserId,
|
||||
notes: `${RUN_ID} other user note on subject mission`,
|
||||
createdAt: new Date('2026-06-25T04:30:00.000Z'),
|
||||
updatedAt: new Date('2026-06-25T04:30:00.000Z'),
|
||||
},
|
||||
]);
|
||||
|
||||
await db.insert(federationPeers).values([
|
||||
{
|
||||
id: peerId,
|
||||
commonName: `${RUN_ID}-active-peer`,
|
||||
displayName: `${RUN_ID} Active Peer`,
|
||||
certPem: '-----BEGIN CERTIFICATE-----\nMOCK\n-----END CERTIFICATE-----\n',
|
||||
certSerial: CERT_SERIAL_HEX,
|
||||
certNotAfter: new Date(Date.now() + 86_400_000),
|
||||
state: 'active',
|
||||
},
|
||||
{
|
||||
id: revokedPeerId,
|
||||
commonName: `${RUN_ID}-revoked-peer`,
|
||||
displayName: `${RUN_ID} Revoked Peer`,
|
||||
certPem: '-----BEGIN CERTIFICATE-----\nMOCK\n-----END CERTIFICATE-----\n',
|
||||
certSerial: `${CERT_SERIAL_HEX}${RUN_ID.replace(/-/g, '').slice(0, 8).toUpperCase()}`,
|
||||
certNotAfter: new Date(Date.now() + 86_400_000),
|
||||
state: 'active',
|
||||
},
|
||||
]);
|
||||
|
||||
await db.insert(federationGrants).values([
|
||||
{
|
||||
id: activeGrantId,
|
||||
peerId,
|
||||
subjectUserId,
|
||||
status: 'active',
|
||||
scope: {
|
||||
resources: ['tasks', 'notes'],
|
||||
excluded_resources: [],
|
||||
filters: {
|
||||
tasks: { include_personal: true, include_teams: [] },
|
||||
notes: { include_personal: true, include_teams: [] },
|
||||
},
|
||||
max_rows_per_query: 2,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: revokedGrantId,
|
||||
peerId,
|
||||
subjectUserId,
|
||||
status: 'revoked',
|
||||
revokedAt: new Date(),
|
||||
revokedReason: `${RUN_ID} revoked grant fixture`,
|
||||
scope: {
|
||||
resources: ['tasks'],
|
||||
excluded_resources: [],
|
||||
max_rows_per_query: 2,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
return {
|
||||
subjectUserId,
|
||||
otherUserId,
|
||||
peerId,
|
||||
revokedPeerId,
|
||||
activeGrantId,
|
||||
revokedGrantId,
|
||||
subjectProjectId,
|
||||
subjectMissionId,
|
||||
otherProjectId,
|
||||
teamId,
|
||||
unauthorizedTeamId,
|
||||
teamProjectId,
|
||||
taskIds,
|
||||
excludedTaskIds,
|
||||
subjectNoteId,
|
||||
otherUserNoteId,
|
||||
};
|
||||
}
|
||||
|
||||
async function cleanupFixtures(db: Db, ids: TestIds | undefined): Promise<void> {
|
||||
if (!ids) {
|
||||
return;
|
||||
}
|
||||
|
||||
await db
|
||||
.delete(missionTasks)
|
||||
.where(inArray(missionTasks.id, [ids.subjectNoteId, ids.otherUserNoteId]))
|
||||
.catch(() => {});
|
||||
await db
|
||||
.delete(tasks)
|
||||
.where(inArray(tasks.id, [...ids.taskIds, ...ids.excludedTaskIds]))
|
||||
.catch(() => {});
|
||||
await db
|
||||
.delete(missions)
|
||||
.where(eq(missions.id, ids.subjectMissionId))
|
||||
.catch(() => {});
|
||||
await db
|
||||
.delete(projects)
|
||||
.where(inArray(projects.id, [ids.subjectProjectId, ids.otherProjectId, ids.teamProjectId]))
|
||||
.catch(() => {});
|
||||
await db
|
||||
.delete(teamMembers)
|
||||
.where(
|
||||
and(
|
||||
eq(teamMembers.userId, ids.subjectUserId),
|
||||
inArray(teamMembers.teamId, [ids.teamId, ids.unauthorizedTeamId]),
|
||||
),
|
||||
)
|
||||
.catch(() => {});
|
||||
await db
|
||||
.delete(teams)
|
||||
.where(inArray(teams.id, [ids.teamId, ids.unauthorizedTeamId]))
|
||||
.catch(() => {});
|
||||
await db
|
||||
.delete(federationGrants)
|
||||
.where(inArray(federationGrants.id, [ids.activeGrantId, ids.revokedGrantId]))
|
||||
.catch(() => {});
|
||||
await db
|
||||
.delete(federationPeers)
|
||||
.where(inArray(federationPeers.id, [ids.peerId, ids.revokedPeerId]))
|
||||
.catch(() => {});
|
||||
await db
|
||||
.delete(users)
|
||||
.where(inArray(users.id, [ids.subjectUserId, ids.otherUserId]))
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
describe.skipIf(!run)('federation M3 list verb — single-gateway integration', () => {
|
||||
let handle: DbHandle;
|
||||
let db: Db;
|
||||
let moduleRef: TestingModule;
|
||||
let guard: FederationAuthGuard;
|
||||
let listController: ListController;
|
||||
let ids: TestIds | undefined;
|
||||
|
||||
beforeAll(async () => {
|
||||
handle = createDb(PG_URL);
|
||||
db = handle.db;
|
||||
ids = await seedFixtures(db);
|
||||
|
||||
moduleRef = await Test.createTestingModule({
|
||||
controllers: [ListController],
|
||||
providers: [
|
||||
{ provide: DB, useValue: db },
|
||||
GrantsService,
|
||||
FederationAuthGuard,
|
||||
FederationScopeService,
|
||||
FederationListQueryService,
|
||||
],
|
||||
}).compile();
|
||||
|
||||
guard = moduleRef.get(FederationAuthGuard);
|
||||
listController = moduleRef.get(ListController);
|
||||
}, 30_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await moduleRef?.close().catch((e: unknown) => console.error('[fed-m3-10 cleanup]', e));
|
||||
await cleanupFixtures(db, ids).catch((e: unknown) => console.error('[fed-m3-10 cleanup]', e));
|
||||
await handle?.close().catch((e: unknown) => console.error('[fed-m3-10 cleanup]', e));
|
||||
});
|
||||
|
||||
it('#6 — rejects a client cert with malformed/missing Mosaic OIDs with 401', async () => {
|
||||
const malformedOidCert = await makeSelfSignedCert();
|
||||
const request = makeFederationRequest(malformedOidCert);
|
||||
const { context, sent } = makeGuardContext(request);
|
||||
|
||||
await expect(guard.canActivate(context)).resolves.toBe(false);
|
||||
expect(sent.statusCode).toBe(401);
|
||||
expect(sent.payload).toMatchObject({
|
||||
error: {
|
||||
code: 'unauthorized',
|
||||
message: expect.stringContaining('missing required OID'),
|
||||
},
|
||||
});
|
||||
expect(request.federationContext).toBeUndefined();
|
||||
});
|
||||
|
||||
it('#6 — rejects a valid client cert when its grant is revoked with 403', async () => {
|
||||
expect(ids).toBeDefined();
|
||||
const revokedCert = await makeMosaicIssuedCert({
|
||||
grantId: ids!.revokedGrantId,
|
||||
subjectUserId: ids!.subjectUserId,
|
||||
});
|
||||
const request = makeFederationRequest(revokedCert);
|
||||
const { context, sent } = makeGuardContext(request);
|
||||
|
||||
await expect(guard.canActivate(context)).resolves.toBe(false);
|
||||
expect(sent.statusCode).toBe(403);
|
||||
expect(sent.payload).toMatchObject({
|
||||
error: {
|
||||
code: 'forbidden',
|
||||
message: 'Federation access denied',
|
||||
},
|
||||
});
|
||||
expect(request.federationContext).toBeUndefined();
|
||||
});
|
||||
|
||||
it('#7 — enforces max_rows_per_query on POST /api/federation/v1/list/:resource', async () => {
|
||||
expect(ids).toBeDefined();
|
||||
const activeCert = await makeMosaicIssuedCert({
|
||||
grantId: ids!.activeGrantId,
|
||||
subjectUserId: ids!.subjectUserId,
|
||||
});
|
||||
const request = makeFederationRequest(activeCert);
|
||||
const { context } = makeGuardContext(request);
|
||||
|
||||
await expect(guard.canActivate(context)).resolves.toBe(true);
|
||||
|
||||
const response = await listController.list('tasks', request, { limit: 100 });
|
||||
const returnedIds = response.items.map((item) => item['id']);
|
||||
|
||||
expect(response.items).toHaveLength(2);
|
||||
expect(response._truncated).toBe(true);
|
||||
expect(response.nextCursor).toEqual(expect.any(String));
|
||||
expect(returnedIds).toEqual([ids!.taskIds[0], ids!.taskIds[1]]);
|
||||
expect(returnedIds).not.toContain(ids!.taskIds[2]);
|
||||
for (const excludedId of ids!.excludedTaskIds) {
|
||||
expect(returnedIds).not.toContain(excludedId);
|
||||
}
|
||||
expect(response.items.every((item) => item._source === 'local')).toBe(true);
|
||||
});
|
||||
|
||||
it('excludes another user mission task notes on the same authorized mission', async () => {
|
||||
expect(ids).toBeDefined();
|
||||
const activeCert = await makeMosaicIssuedCert({
|
||||
grantId: ids!.activeGrantId,
|
||||
subjectUserId: ids!.subjectUserId,
|
||||
});
|
||||
const request = makeFederationRequest(activeCert);
|
||||
const { context } = makeGuardContext(request);
|
||||
|
||||
await expect(guard.canActivate(context)).resolves.toBe(true);
|
||||
|
||||
const response = await listController.list('notes', request, { limit: 10 });
|
||||
const returnedIds = response.items.map((item) => item['id']);
|
||||
|
||||
expect(returnedIds).toEqual([ids!.subjectNoteId]);
|
||||
expect(returnedIds).not.toContain(ids!.otherUserNoteId);
|
||||
expect(response.items.every((item) => item._source === 'local')).toBe(true);
|
||||
});
|
||||
|
||||
it('fails closed for unsupported list resources', async () => {
|
||||
expect(ids).toBeDefined();
|
||||
const activeCert = await makeMosaicIssuedCert({
|
||||
grantId: ids!.activeGrantId,
|
||||
subjectUserId: ids!.subjectUserId,
|
||||
});
|
||||
const request = makeFederationRequest(activeCert);
|
||||
const { context } = makeGuardContext(request);
|
||||
|
||||
await expect(guard.canActivate(context)).resolves.toBe(true);
|
||||
|
||||
await expect(listController.list('widgets', request, {})).rejects.toMatchObject({
|
||||
response: {
|
||||
error: {
|
||||
code: 'scope_violation',
|
||||
message: 'Requested federation resource is not supported',
|
||||
},
|
||||
},
|
||||
status: 403,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,213 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { InMemoryDurableSessionStore } from '@mosaicstack/agent';
|
||||
import {
|
||||
createDiscordIngressEnvelope,
|
||||
DiscordPlugin,
|
||||
type DiscordIngressPayload,
|
||||
} from '@mosaicstack/discord-plugin';
|
||||
import { InteractionController } from '../../agent/interaction.controller.js';
|
||||
import { RuntimeProviderService } from '../../agent/runtime-provider-registry.service.js';
|
||||
import { DurableSessionService } from '../../agent/durable-session.service.js';
|
||||
import { ChatGateway } from '../../chat/chat.gateway.js';
|
||||
import { CommandAuthorizationService } from '../../commands/command-authorization.service.js';
|
||||
|
||||
const SERVICE_TOKEN = 'test-discord-service-token';
|
||||
const envKeys = [
|
||||
'DISCORD_SERVICE_TOKEN',
|
||||
'DISCORD_SERVICE_TENANT_ID',
|
||||
'DISCORD_INTERACTION_BINDINGS',
|
||||
'DISCORD_ALLOWED_GUILD_IDS',
|
||||
'DISCORD_ALLOWED_CHANNEL_IDS',
|
||||
'DISCORD_ALLOWED_USER_IDS',
|
||||
'MOSAIC_AGENT_NAME',
|
||||
] as const;
|
||||
const priorEnv = new Map<string, string | undefined>();
|
||||
|
||||
function payload(content: string, messageId: string, correlationId: string): DiscordIngressPayload {
|
||||
return {
|
||||
content,
|
||||
messageId,
|
||||
correlationId,
|
||||
guildId: 'guild-1',
|
||||
channelId: 'channel-1',
|
||||
userId: 'discord-admin-1',
|
||||
conversationId: 'Nova:discord:channel-1',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The chat runtime router must never be exercised on the Discord approval/stop control paths —
|
||||
* those paths run entirely through the command-authorization, runtime-provider and durable-session
|
||||
* dependencies. Placed in the gateway's chat-runtime-router slot (the former direct `AgentService`
|
||||
* slot) so any accidental chat-runtime dispatch throws loudly instead of silently passing. Because
|
||||
* approval/stop never resolve a chat runtime, this fixture is never triggered and the integration
|
||||
* stays a GREEN cross-surface control.
|
||||
*/
|
||||
function failIfUsedChatRuntimeRouter() {
|
||||
return {
|
||||
onModuleInit: () => {
|
||||
throw new Error('chat runtime router must not initialise on the Discord control path');
|
||||
},
|
||||
get active(): never {
|
||||
throw new Error('chat runtime must not be resolved on the Discord approval/stop path');
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function authorization(): CommandAuthorizationService {
|
||||
const entries = new Map<string, string>();
|
||||
return new CommandAuthorizationService(
|
||||
{
|
||||
select: () => ({
|
||||
from: () => ({ where: () => ({ limit: async () => [{ role: 'admin' }] }) }),
|
||||
}),
|
||||
} as never,
|
||||
{
|
||||
get: async (key: string) => entries.get(key) ?? null,
|
||||
set: async (key: string, value: string) => entries.set(key, value),
|
||||
del: async (key: string) => Number(entries.delete(key)),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
describe('interaction Discord/CLI durable-session integration', () => {
|
||||
afterEach(() => {
|
||||
for (const key of envKeys) {
|
||||
const value = priorEnv.get(key);
|
||||
if (value === undefined) delete process.env[key];
|
||||
else process.env[key] = value;
|
||||
}
|
||||
priorEnv.clear();
|
||||
});
|
||||
|
||||
it('enrolls through the CLI surface then resolves the same durable session from Discord', async () => {
|
||||
for (const key of envKeys) priorEnv.set(key, process.env[key]);
|
||||
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
|
||||
process.env['DISCORD_SERVICE_TOKEN'] = SERVICE_TOKEN;
|
||||
process.env['DISCORD_SERVICE_TENANT_ID'] = 'tenant-1';
|
||||
process.env['DISCORD_ALLOWED_GUILD_IDS'] = 'guild-1';
|
||||
process.env['DISCORD_ALLOWED_CHANNEL_IDS'] = 'channel-1';
|
||||
process.env['DISCORD_ALLOWED_USER_IDS'] = 'discord-admin-1';
|
||||
process.env['DISCORD_INTERACTION_BINDINGS'] = JSON.stringify([
|
||||
{
|
||||
instanceId: 'Nova',
|
||||
agentConfigId: 'agent-config-nova',
|
||||
guildId: 'guild-1',
|
||||
channelId: 'channel-1',
|
||||
pairedUsers: {
|
||||
'discord-admin-1': { role: 'admin', mosaicUserId: 'mosaic-admin-1' },
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const durable = new DurableSessionService(
|
||||
new InMemoryDurableSessionStore() as never,
|
||||
{} as never,
|
||||
);
|
||||
const enrollmentRuntime = {
|
||||
listSessions: vi.fn().mockResolvedValue([{ id: 'runtime-1' }]),
|
||||
};
|
||||
const controller = new InteractionController(enrollmentRuntime as never, durable);
|
||||
await controller.enroll(
|
||||
'Nova',
|
||||
'Nova:discord:channel-1',
|
||||
{ providerId: 'fleet', runtimeSessionId: 'runtime-1' },
|
||||
{ id: 'mosaic-admin-1', tenantId: 'tenant-1' },
|
||||
'cli-enrollment-correlation',
|
||||
);
|
||||
|
||||
const authz = authorization();
|
||||
const terminated = vi.fn().mockResolvedValue(undefined);
|
||||
const runtime = new RuntimeProviderService(
|
||||
{
|
||||
require: () => ({
|
||||
capabilities: async () => ({ supported: ['session.terminate'] }),
|
||||
terminate: terminated,
|
||||
}),
|
||||
} as never,
|
||||
{ record: async () => undefined } as never,
|
||||
{
|
||||
consume: (approvalId, action) =>
|
||||
authz.consumeRuntimeTerminationApproval(approvalId, action),
|
||||
},
|
||||
);
|
||||
const gateway = new ChatGateway(
|
||||
failIfUsedChatRuntimeRouter() as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
authz,
|
||||
runtime,
|
||||
durable,
|
||||
);
|
||||
const client = { data: { discordService: true }, emit: vi.fn() };
|
||||
const plugin = new DiscordPlugin({
|
||||
token: 'unused',
|
||||
gatewayUrl: 'http://unused',
|
||||
serviceToken: SERVICE_TOKEN,
|
||||
allowedGuildIds: ['guild-1'],
|
||||
allowedChannelIds: ['channel-1'],
|
||||
allowedUserIds: ['discord-admin-1'],
|
||||
interactionBindings: [
|
||||
{
|
||||
instanceId: 'Nova',
|
||||
agentConfigId: 'agent-config-nova',
|
||||
guildId: 'guild-1',
|
||||
channelId: 'channel-1',
|
||||
pairedUsers: {
|
||||
'discord-admin-1': { role: 'admin', mosaicUserId: 'mosaic-admin-1' },
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
const pluginInternals = plugin as unknown as {
|
||||
client: { user: { id: string } };
|
||||
socket: { connected: boolean; emit: ReturnType<typeof vi.fn> };
|
||||
handleDiscordMessage(message: unknown): void;
|
||||
};
|
||||
const pluginSocket = { connected: true, emit: vi.fn() };
|
||||
pluginInternals.client = { user: { id: 'bot-1' } };
|
||||
pluginInternals.socket = pluginSocket;
|
||||
pluginInternals.handleDiscordMessage({
|
||||
id: 'approve-1',
|
||||
guildId: 'guild-1',
|
||||
channelId: 'channel-1',
|
||||
author: { id: 'discord-admin-1', bot: false },
|
||||
mentions: { has: () => true },
|
||||
content: '<@bot-1> /approve',
|
||||
channel: { parentId: null },
|
||||
attachments: new Map(),
|
||||
});
|
||||
|
||||
expect(pluginSocket.emit).toHaveBeenCalledWith('discord:approve', expect.any(Object));
|
||||
const approvalEnvelope = pluginSocket.emit.mock.calls[0]?.[1];
|
||||
await gateway.handleDiscordApproval(client as never, approvalEnvelope);
|
||||
const approval = client.emit.mock.calls.find(
|
||||
([event]) => event === 'discord:approval',
|
||||
)?.[1] as {
|
||||
approvalId: string;
|
||||
success: boolean;
|
||||
};
|
||||
expect(approval.success).toBe(true);
|
||||
|
||||
await gateway.handleDiscordStop(
|
||||
client as never,
|
||||
createDiscordIngressEnvelope(
|
||||
payload(`/stop ${approval.approvalId}`, 'stop-1', 'discord-stop-correlation'),
|
||||
SERVICE_TOKEN,
|
||||
),
|
||||
);
|
||||
|
||||
expect(terminated).toHaveBeenCalledWith(
|
||||
'runtime-1',
|
||||
approval.approvalId,
|
||||
expect.objectContaining({ actorId: 'mosaic-admin-1' }),
|
||||
);
|
||||
expect(client.emit).toHaveBeenCalledWith('discord:stop', {
|
||||
correlationId: 'discord-stop-correlation',
|
||||
success: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,332 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,150 @@
|
||||
import { ForbiddenException, NotFoundException } from '@nestjs/common';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { ConversationsController } from '../conversations/conversations.controller.js';
|
||||
import { MissionsController } from '../missions/missions.controller.js';
|
||||
import { ProjectsController } from '../projects/projects.controller.js';
|
||||
import { TasksController } from '../tasks/tasks.controller.js';
|
||||
|
||||
function createBrain() {
|
||||
return {
|
||||
conversations: {
|
||||
findAll: vi.fn(),
|
||||
findById: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
findMessages: vi.fn(),
|
||||
addMessage: vi.fn(),
|
||||
},
|
||||
projects: {
|
||||
findAll: vi.fn(),
|
||||
findAllForUser: vi.fn(),
|
||||
findById: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
},
|
||||
missions: {
|
||||
findAll: vi.fn(),
|
||||
findAllByUser: vi.fn(),
|
||||
findById: vi.fn(),
|
||||
findByIdAndUser: vi.fn(),
|
||||
findByProject: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
},
|
||||
missionTasks: {
|
||||
findByMissionAndUser: vi.fn(),
|
||||
findByIdAndUser: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
},
|
||||
tasks: {
|
||||
findAll: vi.fn(),
|
||||
findById: vi.fn(),
|
||||
findByProject: vi.fn(),
|
||||
findByMission: vi.fn(),
|
||||
findByStatus: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('Resource ownership checks', () => {
|
||||
it('forbids access to another user conversation', async () => {
|
||||
const brain = createBrain();
|
||||
// The repo enforces ownership via the WHERE clause; it returns undefined when the
|
||||
// conversation does not belong to the requesting user.
|
||||
brain.conversations.findById.mockResolvedValue(undefined);
|
||||
const controller = new ConversationsController(brain as never, { runtimeMode: 'legacy' });
|
||||
|
||||
await expect(controller.findOne('conv-1', { id: 'user-1' })).rejects.toBeInstanceOf(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
|
||||
it('forbids access to another user project', async () => {
|
||||
const brain = createBrain();
|
||||
brain.projects.findById.mockResolvedValue({ id: 'project-1', ownerId: 'user-2' });
|
||||
const teamsService = { canAccessProject: vi.fn().mockResolvedValue(false) };
|
||||
const controller = new ProjectsController(brain as never, teamsService as never);
|
||||
|
||||
await expect(controller.findOne('project-1', { id: 'user-1' })).rejects.toBeInstanceOf(
|
||||
ForbiddenException,
|
||||
);
|
||||
});
|
||||
|
||||
it('forbids access to a mission owned by another user', async () => {
|
||||
const brain = createBrain();
|
||||
// findByIdAndUser returns undefined when the mission doesn't belong to the user
|
||||
brain.missions.findByIdAndUser.mockResolvedValue(undefined);
|
||||
const controller = new MissionsController(brain as never);
|
||||
|
||||
await expect(controller.findOne('mission-1', { id: 'user-1' })).rejects.toBeInstanceOf(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
|
||||
it('forbids access to a task owned by another project owner', async () => {
|
||||
const brain = createBrain();
|
||||
brain.tasks.findById.mockResolvedValue({ id: 'task-1', projectId: 'project-1' });
|
||||
brain.projects.findById.mockResolvedValue({ id: 'project-1', ownerId: 'user-2' });
|
||||
const controller = new TasksController(brain as never);
|
||||
|
||||
await expect(controller.findOne('task-1', { id: 'user-1' })).rejects.toBeInstanceOf(
|
||||
ForbiddenException,
|
||||
);
|
||||
});
|
||||
|
||||
it('forbids creating a task with an unowned project', async () => {
|
||||
const brain = createBrain();
|
||||
brain.projects.findById.mockResolvedValue({ id: 'project-1', ownerId: 'user-2' });
|
||||
const controller = new TasksController(brain as never);
|
||||
|
||||
await expect(
|
||||
controller.create(
|
||||
{
|
||||
title: 'Task',
|
||||
projectId: 'project-1',
|
||||
},
|
||||
{ id: 'user-1' },
|
||||
),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
});
|
||||
|
||||
it('forbids listing tasks for an unowned project', async () => {
|
||||
const brain = createBrain();
|
||||
brain.projects.findById.mockResolvedValue({ id: 'project-1', ownerId: 'user-2' });
|
||||
const controller = new TasksController(brain as never);
|
||||
|
||||
await expect(
|
||||
controller.list({ id: 'user-1' }, 'project-1', undefined, undefined),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
});
|
||||
|
||||
it('lists only tasks for the current user owned projects when no filter is provided', async () => {
|
||||
const brain = createBrain();
|
||||
brain.projects.findAll.mockResolvedValue([
|
||||
{ id: 'project-1', ownerId: 'user-1' },
|
||||
{ id: 'project-2', ownerId: 'user-2' },
|
||||
]);
|
||||
brain.missions.findAll.mockResolvedValue([{ id: 'mission-1', projectId: 'project-1' }]);
|
||||
brain.tasks.findAll.mockResolvedValue([
|
||||
{ id: 'task-1', projectId: 'project-1' },
|
||||
{ id: 'task-2', missionId: 'mission-1' },
|
||||
{ id: 'task-3', projectId: 'project-2' },
|
||||
]);
|
||||
const controller = new TasksController(brain as never);
|
||||
|
||||
await expect(
|
||||
controller.list({ id: 'user-1' }, undefined, undefined, undefined),
|
||||
).resolves.toEqual([
|
||||
{ id: 'task-1', projectId: 'project-1' },
|
||||
{ id: 'task-2', missionId: 'mission-1' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,377 @@
|
||||
/**
|
||||
* M5-008: Session hardening verification tests.
|
||||
*
|
||||
* Verifies:
|
||||
* 1. /model command switches model → session:info reflects updated modelId
|
||||
* 2. /agent command switches agent config → system prompt / agentName changes
|
||||
* 3. Session resume binds to a conversation (history injected via conversationHistory option)
|
||||
* 4. Session metrics track token usage and message count correctly
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import type {
|
||||
AgentSession,
|
||||
AgentSessionOptions,
|
||||
ConversationHistoryMessage,
|
||||
} from '../agent/agent.service.js';
|
||||
import type { SessionInfoDto, SessionMetrics, SessionTokenMetrics } from '../agent/session.dto.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers — minimal AgentSession fixture
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makeMetrics(overrides?: Partial<SessionMetrics>): SessionMetrics {
|
||||
return {
|
||||
tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
modelSwitches: 0,
|
||||
messageCount: 0,
|
||||
lastActivityAt: new Date().toISOString(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeSession(overrides?: Partial<AgentSession>): AgentSession {
|
||||
return {
|
||||
id: 'session-001',
|
||||
provider: 'anthropic',
|
||||
modelId: 'claude-3-5-sonnet-20241022',
|
||||
piSession: {} as AgentSession['piSession'],
|
||||
listeners: new Set(),
|
||||
unsubscribe: vi.fn(),
|
||||
createdAt: Date.now(),
|
||||
promptCount: 0,
|
||||
channels: new Set(),
|
||||
skillPromptAdditions: [],
|
||||
sandboxDir: '/tmp',
|
||||
allowedTools: null,
|
||||
metrics: makeMetrics(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function sessionToInfo(session: AgentSession): SessionInfoDto {
|
||||
return {
|
||||
id: session.id,
|
||||
provider: session.provider,
|
||||
modelId: session.modelId,
|
||||
...(session.agentName ? { agentName: session.agentName } : {}),
|
||||
createdAt: new Date(session.createdAt).toISOString(),
|
||||
promptCount: session.promptCount,
|
||||
channels: Array.from(session.channels),
|
||||
durationMs: Date.now() - session.createdAt,
|
||||
metrics: { ...session.metrics },
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Replicated AgentService methods (tested in isolation without full DI setup)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function updateSessionModel(session: AgentSession, modelId: string): void {
|
||||
session.modelId = modelId;
|
||||
session.metrics.modelSwitches += 1;
|
||||
session.metrics.lastActivityAt = new Date().toISOString();
|
||||
}
|
||||
|
||||
function applyAgentConfig(
|
||||
session: AgentSession,
|
||||
agentConfigId: string,
|
||||
agentName: string,
|
||||
modelId?: string,
|
||||
): void {
|
||||
session.agentConfigId = agentConfigId;
|
||||
session.agentName = agentName;
|
||||
if (modelId) {
|
||||
updateSessionModel(session, modelId);
|
||||
}
|
||||
}
|
||||
|
||||
function recordTokenUsage(session: AgentSession, tokens: SessionTokenMetrics): void {
|
||||
session.metrics.tokens.input += tokens.input;
|
||||
session.metrics.tokens.output += tokens.output;
|
||||
session.metrics.tokens.cacheRead += tokens.cacheRead;
|
||||
session.metrics.tokens.cacheWrite += tokens.cacheWrite;
|
||||
session.metrics.tokens.total += tokens.total;
|
||||
session.metrics.lastActivityAt = new Date().toISOString();
|
||||
}
|
||||
|
||||
function recordMessage(session: AgentSession): void {
|
||||
session.metrics.messageCount += 1;
|
||||
session.metrics.lastActivityAt = new Date().toISOString();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. /model command — switches model → session:info updated
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('/model command — model switch reflected in session:info', () => {
|
||||
let session: AgentSession;
|
||||
|
||||
beforeEach(() => {
|
||||
session = makeSession();
|
||||
});
|
||||
|
||||
it('updates modelId when /model is called with a model name', () => {
|
||||
updateSessionModel(session, 'claude-opus-4-5-20251001');
|
||||
|
||||
expect(session.modelId).toBe('claude-opus-4-5-20251001');
|
||||
});
|
||||
|
||||
it('increments modelSwitches metric after /model command', () => {
|
||||
expect(session.metrics.modelSwitches).toBe(0);
|
||||
|
||||
updateSessionModel(session, 'gpt-4o');
|
||||
expect(session.metrics.modelSwitches).toBe(1);
|
||||
|
||||
updateSessionModel(session, 'claude-3-5-sonnet-20241022');
|
||||
expect(session.metrics.modelSwitches).toBe(2);
|
||||
});
|
||||
|
||||
it('session:info DTO reflects the new modelId after switch', () => {
|
||||
updateSessionModel(session, 'claude-haiku-3-5-20251001');
|
||||
|
||||
const info = sessionToInfo(session);
|
||||
|
||||
expect(info.modelId).toBe('claude-haiku-3-5-20251001');
|
||||
expect(info.metrics.modelSwitches).toBe(1);
|
||||
});
|
||||
|
||||
it('lastActivityAt is updated after model switch', () => {
|
||||
const before = session.metrics.lastActivityAt;
|
||||
// Ensure at least 1ms passes
|
||||
vi.setSystemTime(Date.now() + 1);
|
||||
updateSessionModel(session, 'new-model');
|
||||
vi.useRealTimers();
|
||||
|
||||
expect(session.metrics.lastActivityAt).not.toBe(before);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. /agent command — switches agent config → system prompt / agentName updated
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('/agent command — agent config applied to session', () => {
|
||||
let session: AgentSession;
|
||||
|
||||
beforeEach(() => {
|
||||
session = makeSession();
|
||||
});
|
||||
|
||||
it('sets agentConfigId and agentName on the session', () => {
|
||||
applyAgentConfig(session, 'agent-uuid-001', 'CodeReviewer');
|
||||
|
||||
expect(session.agentConfigId).toBe('agent-uuid-001');
|
||||
expect(session.agentName).toBe('CodeReviewer');
|
||||
});
|
||||
|
||||
it('also updates modelId when agent config carries a model', () => {
|
||||
applyAgentConfig(session, 'agent-uuid-002', 'DataAnalyst', 'gpt-4o-mini');
|
||||
|
||||
expect(session.agentName).toBe('DataAnalyst');
|
||||
expect(session.modelId).toBe('gpt-4o-mini');
|
||||
expect(session.metrics.modelSwitches).toBe(1);
|
||||
});
|
||||
|
||||
it('does NOT update modelId when agent config has no model', () => {
|
||||
const originalModel = session.modelId;
|
||||
applyAgentConfig(session, 'agent-uuid-003', 'Planner', undefined);
|
||||
|
||||
expect(session.modelId).toBe(originalModel);
|
||||
expect(session.metrics.modelSwitches).toBe(0);
|
||||
});
|
||||
|
||||
it('session:info DTO reflects agentName after /agent switch', () => {
|
||||
applyAgentConfig(session, 'agent-uuid-004', 'DevBot');
|
||||
|
||||
const info = sessionToInfo(session);
|
||||
|
||||
expect(info.agentName).toBe('DevBot');
|
||||
});
|
||||
|
||||
it('multiple /agent calls update to the latest agent', () => {
|
||||
applyAgentConfig(session, 'agent-001', 'FirstAgent');
|
||||
applyAgentConfig(session, 'agent-002', 'SecondAgent');
|
||||
|
||||
expect(session.agentConfigId).toBe('agent-002');
|
||||
expect(session.agentName).toBe('SecondAgent');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 3. Session resume — binds to conversation via conversationHistory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Session resume — binds to conversation', () => {
|
||||
it('conversationHistory option is preserved in session options', () => {
|
||||
const history: ConversationHistoryMessage[] = [
|
||||
{
|
||||
role: 'user',
|
||||
content: 'Hello, what is TypeScript?',
|
||||
createdAt: new Date('2026-01-01T00:01:00Z'),
|
||||
},
|
||||
{
|
||||
role: 'assistant',
|
||||
content: 'TypeScript is a typed superset of JavaScript.',
|
||||
createdAt: new Date('2026-01-01T00:01:05Z'),
|
||||
},
|
||||
];
|
||||
|
||||
const options: AgentSessionOptions = {
|
||||
conversationHistory: history,
|
||||
provider: 'anthropic',
|
||||
modelId: 'claude-3-5-sonnet-20241022',
|
||||
};
|
||||
|
||||
expect(options.conversationHistory).toHaveLength(2);
|
||||
expect(options.conversationHistory![0]!.role).toBe('user');
|
||||
expect(options.conversationHistory![1]!.role).toBe('assistant');
|
||||
});
|
||||
|
||||
it('session with conversationHistory option carries the conversation binding', () => {
|
||||
const CONV_ID = 'conv-resume-001';
|
||||
const history: ConversationHistoryMessage[] = [
|
||||
{ role: 'user', content: 'Prior question', createdAt: new Date('2026-01-01T00:01:00Z') },
|
||||
];
|
||||
|
||||
// Simulate what ChatGateway does: pass conversationId + history to createSession
|
||||
const options: AgentSessionOptions = {
|
||||
conversationHistory: history,
|
||||
};
|
||||
|
||||
// The session ID is the conversationId in the gateway
|
||||
const session = makeSession({ id: CONV_ID });
|
||||
|
||||
expect(session.id).toBe(CONV_ID);
|
||||
expect(options.conversationHistory).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('empty conversationHistory is valid (new conversation)', () => {
|
||||
const options: AgentSessionOptions = {
|
||||
conversationHistory: [],
|
||||
};
|
||||
|
||||
expect(options.conversationHistory).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('resumed session preserves all message roles', () => {
|
||||
const history: ConversationHistoryMessage[] = [
|
||||
{ role: 'system', content: 'You are a helpful assistant.', createdAt: new Date() },
|
||||
{ role: 'user', content: 'Question 1', createdAt: new Date() },
|
||||
{ role: 'assistant', content: 'Answer 1', createdAt: new Date() },
|
||||
{ role: 'user', content: 'Question 2', createdAt: new Date() },
|
||||
];
|
||||
|
||||
const roles = history.map((m) => m.role);
|
||||
expect(roles).toEqual(['system', 'user', 'assistant', 'user']);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 4. Session metrics — token usage and message count
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Session metrics — token usage and message count', () => {
|
||||
let session: AgentSession;
|
||||
|
||||
beforeEach(() => {
|
||||
session = makeSession();
|
||||
});
|
||||
|
||||
it('starts with zero metrics', () => {
|
||||
expect(session.metrics.tokens.input).toBe(0);
|
||||
expect(session.metrics.tokens.output).toBe(0);
|
||||
expect(session.metrics.tokens.total).toBe(0);
|
||||
expect(session.metrics.messageCount).toBe(0);
|
||||
expect(session.metrics.modelSwitches).toBe(0);
|
||||
});
|
||||
|
||||
it('accumulates token usage across multiple turns', () => {
|
||||
recordTokenUsage(session, {
|
||||
input: 100,
|
||||
output: 50,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
total: 150,
|
||||
});
|
||||
recordTokenUsage(session, {
|
||||
input: 200,
|
||||
output: 80,
|
||||
cacheRead: 10,
|
||||
cacheWrite: 5,
|
||||
total: 295,
|
||||
});
|
||||
|
||||
expect(session.metrics.tokens.input).toBe(300);
|
||||
expect(session.metrics.tokens.output).toBe(130);
|
||||
expect(session.metrics.tokens.cacheRead).toBe(10);
|
||||
expect(session.metrics.tokens.cacheWrite).toBe(5);
|
||||
expect(session.metrics.tokens.total).toBe(445);
|
||||
});
|
||||
|
||||
it('increments message count with each recordMessage call', () => {
|
||||
expect(session.metrics.messageCount).toBe(0);
|
||||
|
||||
recordMessage(session);
|
||||
expect(session.metrics.messageCount).toBe(1);
|
||||
|
||||
recordMessage(session);
|
||||
recordMessage(session);
|
||||
expect(session.metrics.messageCount).toBe(3);
|
||||
});
|
||||
|
||||
it('session:info DTO exposes correct metrics snapshot', () => {
|
||||
recordTokenUsage(session, {
|
||||
input: 500,
|
||||
output: 100,
|
||||
cacheRead: 20,
|
||||
cacheWrite: 10,
|
||||
total: 630,
|
||||
});
|
||||
recordMessage(session);
|
||||
recordMessage(session);
|
||||
updateSessionModel(session, 'claude-haiku-3-5-20251001');
|
||||
|
||||
const info = sessionToInfo(session);
|
||||
|
||||
expect(info.metrics.tokens.input).toBe(500);
|
||||
expect(info.metrics.tokens.output).toBe(100);
|
||||
expect(info.metrics.tokens.total).toBe(630);
|
||||
expect(info.metrics.messageCount).toBe(2);
|
||||
expect(info.metrics.modelSwitches).toBe(1);
|
||||
});
|
||||
|
||||
it('metrics are independent per session', () => {
|
||||
const sessionA = makeSession({ id: 'session-A' });
|
||||
const sessionB = makeSession({ id: 'session-B' });
|
||||
|
||||
recordTokenUsage(sessionA, { input: 100, output: 50, cacheRead: 0, cacheWrite: 0, total: 150 });
|
||||
recordMessage(sessionA);
|
||||
|
||||
// Session B should remain at zero
|
||||
expect(sessionB.metrics.tokens.input).toBe(0);
|
||||
expect(sessionB.metrics.messageCount).toBe(0);
|
||||
|
||||
// Session A should have updated values
|
||||
expect(sessionA.metrics.tokens.input).toBe(100);
|
||||
expect(sessionA.metrics.messageCount).toBe(1);
|
||||
});
|
||||
|
||||
it('lastActivityAt is updated after recording tokens', () => {
|
||||
const before = session.metrics.lastActivityAt;
|
||||
vi.setSystemTime(new Date(Date.now() + 100));
|
||||
recordTokenUsage(session, { input: 10, output: 5, cacheRead: 0, cacheWrite: 0, total: 15 });
|
||||
vi.useRealTimers();
|
||||
|
||||
expect(session.metrics.lastActivityAt).not.toBe(before);
|
||||
});
|
||||
|
||||
it('lastActivityAt is updated after recording a message', () => {
|
||||
const before = session.metrics.lastActivityAt;
|
||||
vi.setSystemTime(new Date(Date.now() + 100));
|
||||
recordMessage(session);
|
||||
vi.useRealTimers();
|
||||
|
||||
expect(session.metrics.lastActivityAt).not.toBe(before);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user