mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-04 14:57:42 +00:00
fix: wait out the live server's auth cache before the CLI's destructive cascade
With AUTH_USER_CACHE_MODE=on and the in-process cache, markUserDeleting can only invalidate the CLI's own cache; a live server keeps serving a stale pre-barrier req.user for up to the 5s TTL, and a request from it could recreate conversations/files mid-cascade (only the schedule routes consult the Mongo barrier directly). The script now sleeps the full TTL plus slack after raising the barrier, which covers both cache topologies; the pause is free in a manual admin command.
This commit is contained in:
parent
acfc264bf9
commit
33b754c79e
2 changed files with 42 additions and 4 deletions
|
|
@ -19,6 +19,10 @@ const mockAskQuestion = jest.fn();
|
|||
|
||||
jest.mock('../connect', () => jest.fn(async () => {}));
|
||||
jest.mock('mongoose', () => ({ disconnect: jest.fn(async () => {}) }));
|
||||
// The real package cannot load here: this suite mocks mongoose to a bare stub, and
|
||||
// @librechat/api reads Types.ObjectId at import time. The live CLI harness exercises
|
||||
// the real wiring; this suite only needs the constant.
|
||||
jest.mock('@librechat/api', () => ({ AUTH_USER_DOC_CACHE_TTL_MS: 5000 }));
|
||||
jest.mock('@librechat/data-schemas', () => ({
|
||||
createModels: () => new Proxy({}, { get: (_target, prop) => mockModelFor(prop) }),
|
||||
}));
|
||||
|
|
@ -49,6 +53,7 @@ describe('Delete user CLI', () => {
|
|||
let logSpy;
|
||||
|
||||
beforeEach(() => {
|
||||
delete process.env.AUTH_USER_CACHE_MODE;
|
||||
process.argv = ['node', 'delete-user.js', 'deleted@example.com'];
|
||||
logSpy = jest.spyOn(console, 'log').mockImplementation(() => {});
|
||||
jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
|
@ -109,6 +114,29 @@ describe('Delete user CLI', () => {
|
|||
expect(mockModelFor('Message').deleteMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('waits out the live auth cache before any destructive read or write', async () => {
|
||||
// In-process cache mode: markUserDeleting can only invalidate THIS process's
|
||||
// cache, so a live server keeps serving a pre-barrier req.user for up to the
|
||||
// cache TTL — long enough for a request to recreate data mid-cascade. The
|
||||
// script must sleep through that window between the barrier and the cascade.
|
||||
process.env.AUTH_USER_CACHE_MODE = 'on';
|
||||
const timeoutSpy = jest.spyOn(global, 'setTimeout');
|
||||
try {
|
||||
const started = Date.now();
|
||||
const code = await runCli();
|
||||
expect(code).toBe(0);
|
||||
const waits = timeoutSpy.mock.calls.map((c) => c[1]).filter((ms) => ms >= 5000);
|
||||
expect(waits.length).toBeGreaterThanOrEqual(1);
|
||||
expect(Date.now() - started).toBeGreaterThanOrEqual(5000);
|
||||
const barrierAt = mockMarkUserDeleting.mock.invocationCallOrder[0];
|
||||
const countAt = mockModelFor('ScheduleRun').countDocuments.mock.invocationCallOrder[0];
|
||||
expect(barrierAt).toBeLessThan(countAt);
|
||||
} finally {
|
||||
delete process.env.AUTH_USER_CACHE_MODE;
|
||||
timeoutSpy.mockRestore();
|
||||
}
|
||||
}, 20000);
|
||||
|
||||
it('refuses while a scheduled run is still active', async () => {
|
||||
mockModelFor('ScheduleRun').countDocuments.mockResolvedValue(2);
|
||||
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ const {
|
|||
ScheduleRun,
|
||||
} = require('@librechat/data-schemas').createModels(mongoose);
|
||||
require('module-alias')({ base: path.resolve(__dirname, '..', 'api') });
|
||||
const { AUTH_USER_DOC_CACHE_TTL_MS } = require('@librechat/api');
|
||||
const { markUserDeleting } = require('~/models');
|
||||
const { askQuestion, silentExit } = require('./helpers');
|
||||
const connect = require('./connect');
|
||||
|
|
@ -89,10 +90,6 @@ async function gracefulExit(code = 0) {
|
|||
// Through markUserDeleting, never a raw update: that method also drops the auth
|
||||
// user-doc cache entry, and fails closed — a cache it cannot reach aborts the
|
||||
// deletion rather than proceeding behind a barrier that was never actually raised.
|
||||
// With a shared (Redis) cache this invalidation reaches the live server. With the
|
||||
// in-process cache no external script can, but the residual window is bounded by the
|
||||
// 5s AUTH_USER_DOC_CACHE_TTL_MS, and the schedule-fire guard (isOwnerDeleting) reads
|
||||
// Mongo directly rather than trusting req.user.
|
||||
try {
|
||||
await markUserDeleting(uid);
|
||||
} catch (err) {
|
||||
|
|
@ -101,6 +98,19 @@ async function gracefulExit(code = 0) {
|
|||
return gracefulExit(1);
|
||||
}
|
||||
|
||||
// WAIT OUT the live server's auth cache before anything destructive. With a shared
|
||||
// (Redis) cache the invalidation above reached the server; with the IN-PROCESS cache
|
||||
// no external script can, so a request served from a stale pre-barrier req.user could
|
||||
// still mutate conversations/files during the cascade and recreate data for the
|
||||
// removed account — only the schedule routes consult the Mongo barrier directly.
|
||||
// Sleeping through the full TTL (plus slack) is the one mechanism that covers both
|
||||
// topologies; this is a manual admin command, so the pause is free.
|
||||
if (process.env.AUTH_USER_CACHE_MODE === 'on') {
|
||||
const waitMs = AUTH_USER_DOC_CACHE_TTL_MS + 1000;
|
||||
console.orange(`Waiting ${waitMs}ms for any live server's auth cache to expire...`);
|
||||
await new Promise((resolve) => setTimeout(resolve, waitMs));
|
||||
}
|
||||
|
||||
// REFUSE rather than warn when a scheduled run is in flight. This script talks to the
|
||||
// database directly, so unlike the HTTP deletion paths it cannot abort a live loopback
|
||||
// generation or wait for it to drain. That generation can already have passed its
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue