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:
Danny Avila 2026-07-29 09:13:12 -04:00
parent acfc264bf9
commit 33b754c79e
2 changed files with 42 additions and 4 deletions

View file

@ -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);

View file

@ -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