mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-07-03 04:42:11 +00:00
* fix: Replay MCP OAuth URL for Joined Connections * chore: Sort MCP OAuth Imports * test: Restore MCP OAuth Registry Spies * fix: Replay pending MCP OAuth prompts * fix: Replay MCP OAuth on Stream Resume * fix: Preserve MCP OAuth Replay Context * chore: Format MCP OAuth Replay Context * test: Expect MCP OAuth Replay Expiry * fix: Render pending MCP OAuth prompts * chore: Clean MCP OAuth Replay Type Narrowing * fix: Stabilize new MCP OAuth chats * fix: Re-emit cached MCP OAuth prompts * fix: Replay pending OAuth for selected MCP tools * fix: Avoid stalling pending MCP OAuth replay * test: Clean MCP OAuth review findings * test: Restore MCP OAuth registry spy * fix: Resolve OAuth Typecheck Regressions * fix: Harden MCP OAuth replay edge cases * test: Cover MCP OAuth joined prompt expiry * test: Mark joined OAuth replay fixture * test: Use OAuth fixture for joined replay expiry * fix: Anchor resumed MCP OAuth prompts * fix: Seed resumable turn metadata before MCP init * test: Format resume metadata regression * fix: Prioritize resumable stream routes * fix: Preserve MCP OAuth resume message tree * test: Fix MCP OAuth Resume Test Types * fix: Replay MCP OAuth Regenerate Prompts * fix: Skip OAuth-only Abort Persistence * fix: Stabilize OAuth Resume Replay * fix: Target Non-Tail Regenerate Responses * fix: Scope Regenerate Step Updates * fix: Clean Up OAuth Abort State * fix: Preserve Regenerate Branch Siblings * fix: Preserve OAuth Resume Branch State * fix: Preserve OAuth Branch Resume State * chore: Sort OAuth Resume Imports * fix: Address OAuth Resume Review Findings * test: Fix Abort Fixture Typing
188 lines
5.8 KiB
JavaScript
188 lines
5.8 KiB
JavaScript
const express = require('express');
|
|
const request = require('supertest');
|
|
|
|
const mockGenerationJobManager = {
|
|
getJob: jest.fn(),
|
|
subscribe: jest.fn(),
|
|
getResumeState: jest.fn(),
|
|
abortJob: jest.fn(),
|
|
getActiveJobIdsForUser: jest.fn().mockResolvedValue([]),
|
|
};
|
|
|
|
jest.mock('@librechat/data-schemas', () => ({
|
|
...jest.requireActual('@librechat/data-schemas'),
|
|
logger: {
|
|
debug: jest.fn(),
|
|
warn: jest.fn(),
|
|
error: jest.fn(),
|
|
info: jest.fn(),
|
|
},
|
|
}));
|
|
|
|
jest.mock('@librechat/api', () => ({
|
|
...jest.requireActual('@librechat/api'),
|
|
isEnabled: jest.fn().mockReturnValue(false),
|
|
GenerationJobManager: mockGenerationJobManager,
|
|
}));
|
|
|
|
jest.mock('~/models', () => ({
|
|
saveMessage: jest.fn(),
|
|
}));
|
|
|
|
let mockUserId = 'user-123';
|
|
let mockTenantId;
|
|
|
|
jest.mock('~/server/middleware', () => ({
|
|
uaParser: (req, res, next) => next(),
|
|
checkBan: (req, res, next) => next(),
|
|
requireJwtAuth: (req, res, next) => {
|
|
req.user = { id: mockUserId, tenantId: mockTenantId };
|
|
next();
|
|
},
|
|
messageIpLimiter: (req, res, next) => next(),
|
|
configMiddleware: (req, res, next) => next(),
|
|
messageUserLimiter: (req, res, next) => next(),
|
|
}));
|
|
|
|
jest.mock('~/server/routes/agents/chat', () => require('express').Router());
|
|
jest.mock('~/server/routes/agents/v1', () => {
|
|
const router = require('express').Router();
|
|
router.use((req, res) => res.status(418).json({ error: 'v1 caught stream route' }));
|
|
return { v1: router };
|
|
});
|
|
jest.mock('~/server/routes/agents/openai', () => require('express').Router());
|
|
jest.mock('~/server/routes/agents/responses', () => require('express').Router());
|
|
|
|
const agentsRouter = require('../index');
|
|
const app = express();
|
|
app.use(express.json());
|
|
app.use('/agents', agentsRouter);
|
|
|
|
function mockSubscribeSuccess() {
|
|
mockGenerationJobManager.subscribe.mockImplementation((_streamId, _writeEvent, onDone) => {
|
|
process.nextTick(() => onDone({ done: true }));
|
|
return { unsubscribe: jest.fn() };
|
|
});
|
|
}
|
|
|
|
describe('SSE stream tenant isolation', () => {
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
mockUserId = 'user-123';
|
|
mockTenantId = undefined;
|
|
});
|
|
|
|
describe('GET /chat/stream/:streamId', () => {
|
|
it('returns 403 when a user from a different tenant accesses a stream', async () => {
|
|
mockUserId = 'user-456';
|
|
mockTenantId = 'tenant-b';
|
|
|
|
mockGenerationJobManager.getJob.mockResolvedValue({
|
|
metadata: { userId: 'user-456', tenantId: 'tenant-a' },
|
|
status: 'running',
|
|
});
|
|
|
|
const res = await request(app).get('/agents/chat/stream/stream-123');
|
|
expect(res.status).toBe(403);
|
|
expect(res.body.error).toBe('Unauthorized');
|
|
});
|
|
|
|
it('returns 404 when stream does not exist', async () => {
|
|
mockGenerationJobManager.getJob.mockResolvedValue(null);
|
|
|
|
const res = await request(app).get('/agents/chat/stream/nonexistent');
|
|
expect(res.status).toBe(404);
|
|
});
|
|
|
|
it('proceeds past tenant guard when tenant matches', async () => {
|
|
mockUserId = 'user-123';
|
|
mockTenantId = 'tenant-a';
|
|
mockSubscribeSuccess();
|
|
|
|
mockGenerationJobManager.getJob.mockResolvedValue({
|
|
metadata: { userId: 'user-123', tenantId: 'tenant-a' },
|
|
status: 'running',
|
|
});
|
|
|
|
const res = await request(app).get('/agents/chat/stream/stream-123');
|
|
expect(res.status).toBe(200);
|
|
expect(mockGenerationJobManager.subscribe).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('proceeds past tenant guard when job has no tenantId (single-tenant mode)', async () => {
|
|
mockUserId = 'user-123';
|
|
mockTenantId = undefined;
|
|
mockSubscribeSuccess();
|
|
|
|
mockGenerationJobManager.getJob.mockResolvedValue({
|
|
metadata: { userId: 'user-123' },
|
|
status: 'running',
|
|
});
|
|
|
|
const res = await request(app).get('/agents/chat/stream/stream-123');
|
|
expect(res.status).toBe(200);
|
|
expect(mockGenerationJobManager.subscribe).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('returns 403 when job has tenantId but user has no tenantId', async () => {
|
|
mockUserId = 'user-123';
|
|
mockTenantId = undefined;
|
|
|
|
mockGenerationJobManager.getJob.mockResolvedValue({
|
|
metadata: { userId: 'user-123', tenantId: 'some-tenant' },
|
|
status: 'running',
|
|
});
|
|
|
|
const res = await request(app).get('/agents/chat/stream/stream-123');
|
|
expect(res.status).toBe(403);
|
|
});
|
|
});
|
|
|
|
describe('GET /chat/status/:conversationId', () => {
|
|
it('returns 403 when tenant does not match', async () => {
|
|
mockUserId = 'user-123';
|
|
mockTenantId = 'tenant-b';
|
|
|
|
mockGenerationJobManager.getJob.mockResolvedValue({
|
|
metadata: { userId: 'user-123', tenantId: 'tenant-a' },
|
|
status: 'running',
|
|
});
|
|
|
|
const res = await request(app).get('/agents/chat/status/conv-123');
|
|
expect(res.status).toBe(403);
|
|
expect(res.body.error).toBe('Unauthorized');
|
|
});
|
|
|
|
it('returns status when tenant matches', async () => {
|
|
mockUserId = 'user-123';
|
|
mockTenantId = 'tenant-a';
|
|
|
|
mockGenerationJobManager.getJob.mockResolvedValue({
|
|
metadata: { userId: 'user-123', tenantId: 'tenant-a' },
|
|
status: 'running',
|
|
createdAt: Date.now(),
|
|
});
|
|
mockGenerationJobManager.getResumeState.mockResolvedValue(null);
|
|
|
|
const res = await request(app).get('/agents/chat/status/conv-123');
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.active).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('POST /chat/abort', () => {
|
|
it('returns 403 when tenant does not match', async () => {
|
|
mockUserId = 'user-123';
|
|
mockTenantId = 'tenant-b';
|
|
|
|
mockGenerationJobManager.getJob.mockResolvedValue({
|
|
metadata: { userId: 'user-123', tenantId: 'tenant-a' },
|
|
status: 'running',
|
|
});
|
|
|
|
const res = await request(app).post('/agents/chat/abort').send({ streamId: 'stream-123' });
|
|
expect(res.status).toBe(403);
|
|
expect(res.body.error).toBe('Unauthorized');
|
|
});
|
|
});
|
|
});
|