mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-06-29 10:51:34 +00:00
* feat: add terms acceptance timestamp tracking and migration script * feat: update migration script to use countUsers method for user count * Update config/migrate-terms-timestamp.js Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * feat: enhance terms acceptance response to include acceptance timestamp * fix: make terms acceptance idempotent and fail migration on partial errors Preserve the original termsAcceptedAt on repeat accepts within a terms cycle so retried or duplicate requests no longer overwrite the first acceptance time. Exit the migration script with a non-zero status when any per-user update fails so partial failures are not reported as successful. * style: fix import ordering in data-provider mutations * refactor: record terms acceptance atomically to preserve first-accept time Replace the read-then-write in acceptTermsController with a single atomic acceptTerms method that conditionally stamps termsAcceptedAt via an $ifNull aggregation update. This removes the TOCTOU window where two concurrent first-time accepts could overwrite the earlier acceptance timestamp, while still preserving an existing timestamp and backfilling legacy accepted users. * fix: run terms timestamp migration under system tenant context Wrap the count, cursor scan, and per-user updates in runAsSystem so the tenant isolation plugin does not throw under TENANT_ISOLATION_STRICT or scope the cross-tenant migration to a non-existent tenant, matching the other maintenance migrations. * fix: guard terms backfill against concurrent acceptances Add the missing-timestamp predicate to the per-user updateOne filter so a user who accepts through the API between the cursor read and the write keeps their real acceptance time instead of being overwritten with createdAt. Track modified vs skipped so the summary reflects skips. * fix: scope terms backfill to still-accepted users Add termsAccepted: true to the per-user updateOne filter so a reset that clears acceptance between the cursor read and the write is not re-stamped with createdAt, which would otherwise poison the next acceptance cycle through the $ifNull preserve in acceptTerms. --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
416 lines
14 KiB
JavaScript
416 lines
14 KiB
JavaScript
const mongoose = require('mongoose');
|
|
const { MongoMemoryServer } = require('mongodb-memory-server');
|
|
|
|
jest.mock('@librechat/data-schemas', () => {
|
|
const actual = jest.requireActual('@librechat/data-schemas');
|
|
return {
|
|
...actual,
|
|
logger: {
|
|
debug: jest.fn(),
|
|
error: jest.fn(),
|
|
warn: jest.fn(),
|
|
info: jest.fn(),
|
|
},
|
|
};
|
|
});
|
|
|
|
jest.mock('~/models', () => {
|
|
const _mongoose = require('mongoose');
|
|
return {
|
|
deleteAllUserSessions: jest.fn().mockResolvedValue(undefined),
|
|
deleteAllSharedLinks: jest.fn().mockResolvedValue(undefined),
|
|
deleteAllAgentApiKeys: jest.fn().mockResolvedValue(undefined),
|
|
deleteConversationTags: jest.fn().mockResolvedValue(undefined),
|
|
deleteAllUserMemories: jest.fn().mockResolvedValue(undefined),
|
|
deleteTransactions: jest.fn().mockResolvedValue(undefined),
|
|
deleteAclEntries: jest.fn().mockResolvedValue(undefined),
|
|
updateUserPlugins: jest.fn(),
|
|
deleteAssistants: jest.fn().mockResolvedValue(undefined),
|
|
deleteUserById: jest.fn().mockResolvedValue(undefined),
|
|
deleteUserPrompts: jest.fn().mockResolvedValue(undefined),
|
|
deleteUserSkills: jest.fn().mockResolvedValue(undefined),
|
|
deleteMessages: jest.fn().mockResolvedValue(undefined),
|
|
deleteBalances: jest.fn().mockResolvedValue(undefined),
|
|
deleteActions: jest.fn().mockResolvedValue(undefined),
|
|
deletePresets: jest.fn().mockResolvedValue(undefined),
|
|
deleteUserKey: jest.fn().mockResolvedValue(undefined),
|
|
deleteToolCalls: jest.fn().mockResolvedValue(undefined),
|
|
deleteUserAgents: jest.fn().mockResolvedValue(undefined),
|
|
deleteTokens: jest.fn().mockResolvedValue(undefined),
|
|
deleteConvos: jest.fn().mockResolvedValue(undefined),
|
|
deleteFiles: jest.fn().mockResolvedValue(undefined),
|
|
updateUser: jest.fn(),
|
|
acceptTerms: jest.fn(),
|
|
getUserById: jest.fn().mockResolvedValue(null),
|
|
findToken: jest.fn(),
|
|
getFiles: jest.fn().mockResolvedValue([]),
|
|
removeUserFromAllGroups: jest.fn().mockImplementation(async (userId) => {
|
|
const Group = _mongoose.models.Group;
|
|
await Group.updateMany({ memberIds: userId }, { $pullAll: { memberIds: [userId] } });
|
|
}),
|
|
};
|
|
});
|
|
|
|
jest.mock('~/server/services/PluginService', () => ({
|
|
updateUserPluginAuth: jest.fn(),
|
|
deleteUserPluginAuth: jest.fn().mockResolvedValue(undefined),
|
|
}));
|
|
|
|
jest.mock('~/server/services/AuthService', () => ({
|
|
verifyEmail: jest.fn(),
|
|
resendVerificationEmail: jest.fn(),
|
|
}));
|
|
|
|
jest.mock('sharp', () =>
|
|
jest.fn(() => ({
|
|
metadata: jest.fn().mockResolvedValue({}),
|
|
toFormat: jest.fn().mockReturnThis(),
|
|
toBuffer: jest.fn().mockResolvedValue(Buffer.alloc(0)),
|
|
})),
|
|
);
|
|
|
|
jest.mock('@librechat/api', () => ({
|
|
...jest.requireActual('@librechat/api'),
|
|
needsRefresh: jest.fn(),
|
|
getNewS3URL: jest.fn(),
|
|
}));
|
|
|
|
jest.mock('~/server/services/Files/process', () => ({
|
|
processDeleteRequest: jest.fn().mockResolvedValue({ deletedFileIds: [], failedFileIds: [] }),
|
|
}));
|
|
|
|
jest.mock('~/server/services/Config', () => ({
|
|
getAppConfig: jest.fn().mockResolvedValue({}),
|
|
getMCPManager: jest.fn(),
|
|
getFlowStateManager: jest.fn(),
|
|
getMCPServersRegistry: jest.fn(),
|
|
}));
|
|
|
|
jest.mock('~/cache', () => ({
|
|
getLogStores: jest.fn(),
|
|
}));
|
|
|
|
let mongoServer;
|
|
|
|
beforeAll(async () => {
|
|
mongoServer = await MongoMemoryServer.create();
|
|
await mongoose.connect(mongoServer.getUri());
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await mongoose.disconnect();
|
|
await mongoServer.stop();
|
|
});
|
|
|
|
afterEach(async () => {
|
|
const collections = mongoose.connection.collections;
|
|
for (const key in collections) {
|
|
await collections[key].deleteMany({});
|
|
}
|
|
});
|
|
|
|
const {
|
|
deleteUserController,
|
|
getUserController,
|
|
acceptTermsController,
|
|
resendVerificationController,
|
|
verifyEmailController,
|
|
} = require('./UserController');
|
|
const { Group } = require('~/db/models');
|
|
const { deleteConvos, acceptTerms } = require('~/models');
|
|
const { verifyEmail, resendVerificationEmail } = require('~/server/services/AuthService');
|
|
|
|
describe('verifyEmailController', () => {
|
|
const mockRes = {
|
|
status: jest.fn().mockReturnThis(),
|
|
json: jest.fn().mockReturnThis(),
|
|
};
|
|
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
});
|
|
|
|
it('returns the generic verification error message from service failures', async () => {
|
|
verifyEmail.mockResolvedValue(new Error('Invalid or expired email verification token'));
|
|
|
|
await verifyEmailController(
|
|
{ body: { email: 'user%40example.com', token: 'not-the-token' } },
|
|
mockRes,
|
|
);
|
|
|
|
expect(mockRes.status).toHaveBeenCalledWith(400);
|
|
expect(mockRes.json).toHaveBeenCalledWith({
|
|
message: 'Invalid or expired email verification token',
|
|
});
|
|
});
|
|
|
|
it('uses the service status for resend verification responses', async () => {
|
|
resendVerificationEmail.mockResolvedValue({ status: 500, message: 'Something went wrong.' });
|
|
|
|
await resendVerificationController({ body: { email: 'user@example.com' } }, mockRes);
|
|
|
|
expect(mockRes.status).toHaveBeenCalledWith(500);
|
|
expect(mockRes.json).toHaveBeenCalledWith({ message: 'Something went wrong.' });
|
|
});
|
|
});
|
|
|
|
describe('getUserController', () => {
|
|
const mockRes = {
|
|
status: jest.fn().mockReturnThis(),
|
|
send: jest.fn().mockReturnThis(),
|
|
};
|
|
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
});
|
|
|
|
it('should only expose public user response fields from the request user', async () => {
|
|
const createdAt = new Date('2026-01-01T00:00:00.000Z');
|
|
const updatedAt = new Date('2026-01-02T00:00:00.000Z');
|
|
const req = {
|
|
config: {},
|
|
user: {
|
|
id: 'user-id',
|
|
_id: 'user-id',
|
|
name: 'OpenID User',
|
|
username: 'openid-user',
|
|
email: 'openid@test.com',
|
|
emailVerified: true,
|
|
avatar: '/avatars/user-id.png',
|
|
provider: 'openid',
|
|
role: 'USER',
|
|
plugins: ['web_search'],
|
|
twoFactorEnabled: true,
|
|
termsAccepted: true,
|
|
personalization: { memories: false },
|
|
favorites: [{ model: 'gpt-5', endpoint: 'openAI' }],
|
|
skillStates: { skill_one: true },
|
|
createdAt,
|
|
updatedAt,
|
|
tenantId: 'tenant-id',
|
|
password: 'hashed-password',
|
|
__v: 1,
|
|
totpSecret: 'totp-secret',
|
|
backupCodes: [{ codeHash: 'backup-code' }],
|
|
pendingTotpSecret: 'pending-totp-secret',
|
|
pendingBackupCodes: [{ codeHash: 'pending-backup-code' }],
|
|
refreshToken: [{ refreshToken: 'legacy-refresh-token' }],
|
|
googleId: 'google-id',
|
|
openidId: 'openid-id',
|
|
openidIssuer: 'openid-issuer',
|
|
idOnTheSource: 'external-source-id',
|
|
federatedTokens: {
|
|
access_token: 'access-token',
|
|
id_token: 'id-token',
|
|
refresh_token: 'refresh-token',
|
|
},
|
|
openidTokens: {
|
|
access_token: 'openid-access-token',
|
|
refresh_token: 'openid-refresh-token',
|
|
},
|
|
tokenset: {
|
|
access_token: 'tokenset-access-token',
|
|
refresh_token: 'tokenset-refresh-token',
|
|
},
|
|
safeLookingRuntimeField: 'internal-value',
|
|
},
|
|
};
|
|
|
|
await getUserController(req, mockRes);
|
|
|
|
expect(mockRes.status).toHaveBeenCalledWith(200);
|
|
const sentUser = mockRes.send.mock.calls[0][0];
|
|
expect(sentUser).toMatchObject({
|
|
id: 'user-id',
|
|
_id: 'user-id',
|
|
name: 'OpenID User',
|
|
username: 'openid-user',
|
|
email: 'openid@test.com',
|
|
emailVerified: true,
|
|
avatar: '/avatars/user-id.png',
|
|
provider: 'openid',
|
|
role: 'USER',
|
|
plugins: ['web_search'],
|
|
twoFactorEnabled: true,
|
|
termsAccepted: true,
|
|
personalization: { memories: false },
|
|
favorites: [{ model: 'gpt-5', endpoint: 'openAI' }],
|
|
skillStates: { skill_one: true },
|
|
createdAt,
|
|
updatedAt,
|
|
tenantId: 'tenant-id',
|
|
});
|
|
expect(sentUser).not.toHaveProperty('password');
|
|
expect(sentUser).not.toHaveProperty('__v');
|
|
expect(sentUser).not.toHaveProperty('totpSecret');
|
|
expect(sentUser).not.toHaveProperty('backupCodes');
|
|
expect(sentUser).not.toHaveProperty('pendingTotpSecret');
|
|
expect(sentUser).not.toHaveProperty('pendingBackupCodes');
|
|
expect(sentUser).not.toHaveProperty('refreshToken');
|
|
expect(sentUser).not.toHaveProperty('googleId');
|
|
expect(sentUser).not.toHaveProperty('openidId');
|
|
expect(sentUser).not.toHaveProperty('openidIssuer');
|
|
expect(sentUser).not.toHaveProperty('idOnTheSource');
|
|
expect(sentUser).not.toHaveProperty('federatedTokens');
|
|
expect(sentUser).not.toHaveProperty('openidTokens');
|
|
expect(sentUser).not.toHaveProperty('tokenset');
|
|
expect(sentUser).not.toHaveProperty('safeLookingRuntimeField');
|
|
});
|
|
});
|
|
|
|
describe('acceptTermsController', () => {
|
|
const mockRes = {
|
|
status: jest.fn().mockReturnThis(),
|
|
json: jest.fn().mockReturnThis(),
|
|
};
|
|
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
});
|
|
|
|
it('returns 404 when the user does not exist', async () => {
|
|
acceptTerms.mockResolvedValueOnce(null);
|
|
|
|
await acceptTermsController({ user: { id: 'missing-user' } }, mockRes);
|
|
|
|
expect(acceptTerms).toHaveBeenCalledWith('missing-user');
|
|
expect(mockRes.status).toHaveBeenCalledWith(404);
|
|
expect(mockRes.json).toHaveBeenCalledWith({ message: 'User not found' });
|
|
});
|
|
|
|
it('returns the recorded acceptance timestamp on success', async () => {
|
|
const acceptedAt = new Date('2026-06-14T10:00:00.000Z');
|
|
acceptTerms.mockResolvedValueOnce({ termsAccepted: true, termsAcceptedAt: acceptedAt });
|
|
|
|
await acceptTermsController({ user: { id: 'user-id' } }, mockRes);
|
|
|
|
expect(acceptTerms).toHaveBeenCalledWith('user-id');
|
|
expect(mockRes.status).toHaveBeenCalledWith(200);
|
|
expect(mockRes.json).toHaveBeenCalledWith({
|
|
message: 'Terms accepted successfully',
|
|
termsAcceptedAt: acceptedAt,
|
|
});
|
|
});
|
|
|
|
it('returns 500 when the update throws', async () => {
|
|
acceptTerms.mockRejectedValueOnce(new Error('db down'));
|
|
|
|
await acceptTermsController({ user: { id: 'user-id' } }, mockRes);
|
|
|
|
expect(mockRes.status).toHaveBeenCalledWith(500);
|
|
expect(mockRes.json).toHaveBeenCalledWith({ message: 'Error accepting terms' });
|
|
});
|
|
});
|
|
|
|
describe('deleteUserController', () => {
|
|
const mockRes = {
|
|
status: jest.fn().mockReturnThis(),
|
|
send: jest.fn().mockReturnThis(),
|
|
json: jest.fn().mockReturnThis(),
|
|
};
|
|
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
});
|
|
|
|
it('should return 200 on successful deletion', async () => {
|
|
const userId = new mongoose.Types.ObjectId();
|
|
const req = { user: { id: userId.toString(), _id: userId, email: 'test@test.com' } };
|
|
|
|
await deleteUserController(req, mockRes);
|
|
|
|
expect(mockRes.status).toHaveBeenCalledWith(200);
|
|
expect(mockRes.send).toHaveBeenCalledWith({ message: 'User deleted' });
|
|
});
|
|
|
|
it('should remove the user from all groups via $pullAll', async () => {
|
|
const userId = new mongoose.Types.ObjectId();
|
|
const userIdStr = userId.toString();
|
|
const otherUser = new mongoose.Types.ObjectId().toString();
|
|
|
|
await Group.create([
|
|
{ name: 'Group A', memberIds: [userIdStr, otherUser], source: 'local' },
|
|
{ name: 'Group B', memberIds: [userIdStr], source: 'local' },
|
|
{ name: 'Group C', memberIds: [otherUser], source: 'local' },
|
|
]);
|
|
|
|
const req = { user: { id: userIdStr, _id: userId, email: 'del@test.com' } };
|
|
await deleteUserController(req, mockRes);
|
|
|
|
const groups = await Group.find({}).sort({ name: 1 }).lean();
|
|
expect(groups[0].memberIds).toEqual([otherUser]);
|
|
expect(groups[1].memberIds).toEqual([]);
|
|
expect(groups[2].memberIds).toEqual([otherUser]);
|
|
});
|
|
|
|
it('should handle user that exists in no groups', async () => {
|
|
const userId = new mongoose.Types.ObjectId();
|
|
await Group.create({ name: 'Empty', memberIds: ['someone-else'], source: 'local' });
|
|
|
|
const req = { user: { id: userId.toString(), _id: userId, email: 'no-groups@test.com' } };
|
|
await deleteUserController(req, mockRes);
|
|
|
|
expect(mockRes.status).toHaveBeenCalledWith(200);
|
|
const group = await Group.findOne({ name: 'Empty' }).lean();
|
|
expect(group.memberIds).toEqual(['someone-else']);
|
|
});
|
|
|
|
it('should remove duplicate memberIds if the user appears more than once', async () => {
|
|
const userId = new mongoose.Types.ObjectId();
|
|
const userIdStr = userId.toString();
|
|
|
|
await Group.create({
|
|
name: 'Dupes',
|
|
memberIds: [userIdStr, 'other', userIdStr],
|
|
source: 'local',
|
|
});
|
|
|
|
const req = { user: { id: userIdStr, _id: userId, email: 'dupe@test.com' } };
|
|
await deleteUserController(req, mockRes);
|
|
|
|
const group = await Group.findOne({ name: 'Dupes' }).lean();
|
|
expect(group.memberIds).toEqual(['other']);
|
|
});
|
|
|
|
it('should still succeed when deleteConvos throws', async () => {
|
|
const userId = new mongoose.Types.ObjectId();
|
|
deleteConvos.mockRejectedValueOnce(new Error('no convos'));
|
|
|
|
const req = { user: { id: userId.toString(), _id: userId, email: 'convos@test.com' } };
|
|
await deleteUserController(req, mockRes);
|
|
|
|
expect(mockRes.status).toHaveBeenCalledWith(200);
|
|
expect(mockRes.send).toHaveBeenCalledWith({ message: 'User deleted' });
|
|
});
|
|
|
|
it('should return 500 when a critical operation fails', async () => {
|
|
const userId = new mongoose.Types.ObjectId();
|
|
const { deleteMessages } = require('~/models');
|
|
deleteMessages.mockRejectedValueOnce(new Error('db down'));
|
|
|
|
const req = { user: { id: userId.toString(), _id: userId, email: 'fail@test.com' } };
|
|
await deleteUserController(req, mockRes);
|
|
|
|
expect(mockRes.status).toHaveBeenCalledWith(500);
|
|
expect(mockRes.json).toHaveBeenCalledWith({ message: 'Something went wrong.' });
|
|
});
|
|
|
|
it('should use string user.id (not ObjectId user._id) for memberIds removal', async () => {
|
|
const userId = new mongoose.Types.ObjectId();
|
|
const userIdStr = userId.toString();
|
|
const otherUser = 'other-user-id';
|
|
|
|
await Group.create({
|
|
name: 'StringCheck',
|
|
memberIds: [userIdStr, otherUser],
|
|
source: 'local',
|
|
});
|
|
|
|
const req = { user: { id: userIdStr, _id: userId, email: 'stringcheck@test.com' } };
|
|
await deleteUserController(req, mockRes);
|
|
|
|
const group = await Group.findOne({ name: 'StringCheck' }).lean();
|
|
expect(group.memberIds).toEqual([otherUser]);
|
|
expect(group.memberIds).not.toContain(userIdStr);
|
|
});
|
|
});
|