LibreChat/api/server/routes/__tests__/messages-subagent-thread.spec.js
Danny Avila 67b7b441b2
🛂 feat: Filter Model-Bound Content by Source (#14425)
* feat: introduce optional content protection seam

* feat: enforce source-aware content filters

* feat: complete source-aware content enforcement

* test: activate skill file-text fail-close fixtures

* fix: harden source-aware content filters

* fix: harden model-bound content filtering

* fix: preserve legacy filters and generated files

* fix: inspect shared scalar metadata

* test: align mocks with current dev dependencies

* feat: add persisted content filter safeguards

* feat: complete source-aware content filter enforcement

* fix: move resume content preflight into TypeScript

* fix: close content inspection edge cases

* fix: harden content protection boundaries

* fix: complete content protection safeguards

* test: align persisted memory filter coverage

* fix: reconcile content protection with current dev

* fix: reconcile content protection with latest dev

* fix: close content protection review gaps

* fix: enforce source-aware provider boundaries

* fix: preserve legacy PII preflight semantics

* test: stabilize stored branch preflight fixture

* fix: defer agent writes until protected model admission

* perf: harden source-aware model-bound filtering

* fix: canonicalize provider lineage before validation

* fix: satisfy model-bound callback type checks

* perf: Bound content protection filtering work

* fix: Bound submission array traversal

* fix: Stabilize bounded content snapshots

* fix: Scope model-bound traversal overflows

* fix: Preserve scoped content inspection

* fix: Accumulate aggregate traversal scopes

* fix: centralize content policy boundaries

* test: align deferred tool policy context

* test: align controller policy mocks

* style: normalize content protection imports

* fix: close content policy review gaps

* fix: narrow active skill policy config

* fix: address content protection review boundaries

* fix: retain exact provenance overflow sentinel

* fix: preserve literal and scoped provenance updates

* fix: narrow persisted edit provenance

* fix: isolate exact overflow attribution

* fix: centralize stored prompt protection

* fix: fail closed on incomplete transcript evidence

* fix: align canonical transcript routing

* refactor: centralize content policy preflights

* fix: isolate upload policy error typing

* style: sort policy preflight imports

* refactor: centralize content policy boundaries
2026-08-21 22:43:32 -04:00

162 lines
5.2 KiB
JavaScript

const express = require('express');
const request = require('supertest');
const mockIsSubagentThreadWriteBlocked = jest.fn();
jest.mock('@librechat/agents', () => ({ sleep: jest.fn() }));
jest.mock('@librechat/api', () => ({
createContentFilter: jest.fn(() => (_req, _res, next) => next()),
unescapeLaTeX: jest.fn((value) => value),
countTokens: jest.fn().mockResolvedValue(1),
sendFeedbackScore: jest.fn().mockResolvedValue(undefined),
traceIdForMessage: jest.fn((messageId) => `trace-${messageId}`),
mergeQuotedTextForCount: jest.fn((text) => text),
assertStoredMessageMutationAllowed: jest.fn(),
assertChatMutationAllowed: jest.fn(),
assertStoredMessageBranchAllowed: jest.fn(),
mergeUserSubmittedPaths: (...lists) => [...new Set(lists.flat().filter(Boolean))],
mergeUserSubmittedMessageFieldPaths: (...lists) => lists.flat().filter(Boolean),
isContentFilterError: jest.fn(() => false),
CHILD_THREAD_READ_ONLY_ERROR: 'This subagent thread is view-only.',
isSubagentThreadWriteBlocked: (...args) => mockIsSubagentThreadWriteBlocked(...args),
requireFeedbackEnabled: (req, res, next) => next(),
}));
jest.mock('@librechat/data-schemas', () => ({
...jest.requireActual('@librechat/data-schemas'),
logger: {
debug: jest.fn(),
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
},
}));
jest.mock('librechat-data-provider', () => ({
...jest.requireActual('librechat-data-provider'),
}));
jest.mock('~/models', () => ({
getConvo: jest.fn(),
getMessage: jest.fn(),
getMessages: jest.fn(),
saveConvo: jest.fn(),
saveMessage: jest.fn(),
updateMessage: jest.fn(),
deleteMessages: jest.fn(),
getConvosQueried: jest.fn(),
searchMessages: jest.fn(),
getMessagesByCursor: jest.fn(),
}));
jest.mock('~/server/services/Endpoints/agents/subagentThreadStore', () => ({
isThreadActiveForOwner: jest.fn(),
}));
jest.mock('~/server/services/Artifacts/update', () => ({
findAllArtifacts: jest.fn(),
replaceArtifactContent: jest.fn(),
}));
jest.mock('~/server/middleware', () => ({
requireJwtAuth: (req, _res, next) => next(),
validateMessageReq: (req, _res, next) => next(),
configMiddleware: (req, _res, next) => next(),
sendValidationResponse: jest.fn(),
prepareMessageRequestValidation: jest.fn(),
}));
describe('message mutation policy for durable subagent threads', () => {
let app;
const db = require('~/models');
beforeAll(() => {
const messagesRouter = require('../messages');
app = express();
app.use(express.json());
app.use((req, _res, next) => {
req.user = { id: 'owner-user', tenantId: 'tenant-a' };
next();
});
app.use('/api/messages', messagesRouter);
});
beforeEach(() => {
jest.clearAllMocks();
mockIsSubagentThreadWriteBlocked.mockResolvedValue(true);
db.getMessage.mockResolvedValue({
messageId: 'message-1',
conversationId: 'child-conversation',
isCreatedByUser: false,
content: [],
});
db.getMessages.mockResolvedValue([
{
messageId: 'message-1',
conversationId: 'child-conversation',
isCreatedByUser: false,
content: [],
},
]);
});
it('blocks every transcript-affecting message route through one shared policy', async () => {
const responses = await Promise.all([
request(app)
.post('/api/messages/child-conversation')
.send({ messageId: 'new-message', text: 'write' }),
request(app)
.put('/api/messages/child-conversation/message-1')
.send({ text: 'edit', model: 'gpt-5' }),
request(app).delete('/api/messages/child-conversation/message-1'),
request(app).post('/api/messages/branch').send({
messageId: 'message-1',
agentId: 'agent-1',
}),
request(app).post('/api/messages/artifact/message-1').send({
index: 0,
original: 'before',
updated: 'after',
}),
]);
expect(responses.map((response) => response.status)).toEqual([409, 409, 409, 409, 409]);
for (const response of responses) {
expect(response.body).toEqual({ error: 'This subagent thread is view-only.' });
}
expect(mockIsSubagentThreadWriteBlocked).toHaveBeenCalledTimes(5);
expect(mockIsSubagentThreadWriteBlocked).toHaveBeenCalledWith(
expect.objectContaining({ getConvo: db.getConvo }),
{
userId: 'owner-user',
conversationId: 'child-conversation',
tenantId: 'tenant-a',
},
);
expect(db.saveMessage).not.toHaveBeenCalled();
expect(db.saveConvo).not.toHaveBeenCalled();
expect(db.updateMessage).not.toHaveBeenCalled();
expect(db.deleteMessages).not.toHaveBeenCalled();
});
it('authorizes edits against the message owner conversation, not a writable URL', async () => {
mockIsSubagentThreadWriteBlocked.mockResolvedValue(true);
db.getMessages.mockResolvedValue([
{
messageId: 'message-1',
conversationId: 'child-conversation',
isCreatedByUser: true,
content: [],
},
]);
const response = await request(app)
.put('/api/messages/ordinary-conversation/message-1')
.send({ text: 'forged edit', model: 'gpt-5' });
expect(response.status).toBe(404);
expect(mockIsSubagentThreadWriteBlocked).not.toHaveBeenCalled();
expect(db.updateMessage).not.toHaveBeenCalled();
});
});