LibreChat/packages/api/src/auth/openid.spec.ts
Danny Avila fda1bfc3cc
🔬 ci: Add TypeScript Type Checks to Backend Workflow and Fix All Type Errors (#12451)
* fix(data-schemas): resolve TypeScript strict type check errors in source files

- Constrain ConfigSection to string keys via `string & keyof TCustomConfig`
- Replace broken `z` import from data-provider with TCustomConfig derivation
- Add `_id: Types.ObjectId` to IUser matching other Document interfaces
- Add `federatedTokens` and `openidTokens` optional fields to IUser
- Type mongoose model accessors as `Model<IRole>` and `Model<IUser>`
- Widen `getPremiumRate` param to accept `number | null`
- Widen `bulkWriteAclEntries` ops to untyped `AnyBulkWriteOperation[]`
- Fix `getUserPrincipals` return type to use `PrincipalType` enum
- Add non-null assertions for `connection.db` in migration files
- Import DailyRotateFile constructor directly instead of relying on
  broken module augmentation across mismatched node_modules trees
- Add winston-daily-rotate-file as devDependency for type resolution

* fix(data-schemas): resolve TypeScript type errors in test files

- Replace arbitrary test keys with valid TCustomConfig properties in config.spec
- Use non-null assertions for permission objects in role.methods.spec
- Replace `.SHARED_GLOBAL` access with `.not.toHaveProperty()` for legacy field
- Add non-null assertions for balance, writeRate, readRate in spendTokens.spec
- Update mock user _id to use ObjectId in user.test
- Remove unused Schema import in tenantIndexes.spec

* fix(api): resolve TypeScript strict type check errors across source and test files

- Widen getUserPrincipals dep type in capabilities middleware
- Fix federatedTokens type in createSafeUser return
- Use proper mock req type for read-only properties in preAuthTenant.spec
- Replace `as IUser` casts with ObjectId-typed mocks in openid/oidc specs
- Use TokenExchangeMethodEnum values instead of string literals in MCP specs
- Fix SessionStore type compatibility in sessionCache specs
- Replace `catch (error: any)` with `(error as Error)` in redis specs
- Remove invalid properties from test data in initialize and MCP specs
- Add String.prototype.isWellFormed declaration for sanitizeTitle spec

* fix(client): resolve TypeScript type errors in shared client components

- Add default values for destructured bindings in OGDialogTemplate
- Replace broken ExtendedFile import with inline type in FileIcon

* ci: add TypeScript type-check job to backend review workflow

Add a `typecheck` job that runs `tsc --noEmit` on all four TypeScript
workspaces (data-provider, data-schemas, @librechat/api, @librechat/client)
after the build step. Catches type errors that rollup builds may miss.

* fix(data-schemas): add local type declaration for DailyRotateFile transport

The `winston-daily-rotate-file` package ships a module augmentation for
`winston/lib/winston/transports`, but it fails when winston and
winston-daily-rotate-file resolve from different node_modules trees
(which happens in this monorepo due to npm hoisting).

Add a local `.d.ts` declaration that augments the same module path from
within data-schemas' compilation unit, so `tsc --noEmit` passes while
keeping the original runtime pattern (`new winston.transports.DailyRotateFile`).

* fix: address code review findings from PR #12451

- Restore typed `AnyBulkWriteOperation<AclEntry>[]` on bulkWriteAclEntries,
  cast to untyped only at the tenantSafeBulkWrite call site (Finding 1)
- Type `findUser` model accessor consistently with `findUsers` (Finding 2)
- Replace inline `import('mongoose').ClientSession` with top-level import type
- Use `toHaveLength` for spy assertions in playwright-expect spec file
- Replace numbered Record casts with `.not.toHaveProperty()` in
  role.methods.spec for SHARED_GLOBAL assertions
- Use per-test ObjectIds instead of shared testUserId in openid.spec
- Replace inline `import()` type annotations with top-level SessionData
  import in sessionCache spec
- Remove extraneous blank line in user.ts searchUsers

* refactor: address remaining review findings (4–7)

- Extract OIDCTokens interface in user.ts; deduplicate across IUser fields
  and oidc.ts FederatedTokens (Finding 4)
- Move String.isWellFormed declaration from spec file to project-level
  src/types/es2024-string.d.ts (Finding 5)
- Replace verbose `= undefined` defaults in OGDialogTemplate with null
  coalescing pattern (Finding 6)
- Replace `Record<string, unknown>` TestConfig with named interface
  containing explicit test fields (Finding 7)
2026-03-28 21:06:39 -04:00

480 lines
13 KiB
TypeScript

import { Types } from 'mongoose';
import { ErrorTypes } from 'librechat-data-provider';
import { logger } from '@librechat/data-schemas';
import type { IUser, UserMethods } from '@librechat/data-schemas';
import { findOpenIDUser } from './openid';
function newId() {
return new Types.ObjectId();
}
jest.mock('@librechat/data-schemas', () => ({
...jest.requireActual('@librechat/data-schemas'),
logger: {
warn: jest.fn(),
info: jest.fn(),
},
}));
describe('findOpenIDUser', () => {
let mockFindUser: jest.MockedFunction<UserMethods['findUser']>;
beforeEach(() => {
mockFindUser = jest.fn();
jest.clearAllMocks();
(logger.warn as jest.Mock).mockClear();
(logger.info as jest.Mock).mockClear();
});
describe('Primary condition searches', () => {
it('should find user by openidId', async () => {
const mockUser: IUser = {
_id: newId(),
provider: 'openid',
openidId: 'openid_123',
email: 'user@example.com',
username: 'testuser',
} as IUser;
mockFindUser.mockResolvedValueOnce(mockUser);
const result = await findOpenIDUser({
openidId: 'openid_123',
findUser: mockFindUser,
email: 'user@example.com',
});
expect(mockFindUser).toHaveBeenCalledWith({
$or: [{ openidId: 'openid_123' }],
});
expect(result).toEqual({
user: mockUser,
error: null,
migration: false,
});
});
it('should find user by idOnTheSource', async () => {
const mockUser: IUser = {
_id: newId(),
provider: 'openid',
idOnTheSource: 'source_123',
email: 'user@example.com',
username: 'testuser',
} as IUser;
mockFindUser.mockResolvedValueOnce(mockUser);
const result = await findOpenIDUser({
openidId: 'openid_123',
findUser: mockFindUser,
idOnTheSource: 'source_123',
});
expect(mockFindUser).toHaveBeenCalledWith({
$or: [{ openidId: 'openid_123' }, { idOnTheSource: 'source_123' }],
});
expect(result).toEqual({
user: mockUser,
error: null,
migration: false,
});
});
it('should find user by both openidId and idOnTheSource', async () => {
const mockUser: IUser = {
_id: newId(),
provider: 'openid',
openidId: 'openid_123',
idOnTheSource: 'source_123',
email: 'user@example.com',
username: 'testuser',
} as IUser;
mockFindUser.mockResolvedValueOnce(mockUser);
const result = await findOpenIDUser({
openidId: 'openid_123',
findUser: mockFindUser,
idOnTheSource: 'source_123',
email: 'user@example.com',
});
expect(mockFindUser).toHaveBeenCalledWith({
$or: [{ openidId: 'openid_123' }, { idOnTheSource: 'source_123' }],
});
expect(result).toEqual({
user: mockUser,
error: null,
migration: false,
});
});
});
describe('Email-based searches', () => {
it('should find user by email when primary conditions fail and openidId matches', async () => {
const mockUser: IUser = {
_id: newId(),
provider: 'openid',
openidId: 'openid_123',
email: 'user@example.com',
username: 'testuser',
} as IUser;
mockFindUser.mockResolvedValueOnce(null).mockResolvedValueOnce(mockUser);
const result = await findOpenIDUser({
openidId: 'openid_123',
findUser: mockFindUser,
email: 'user@example.com',
});
expect(mockFindUser).toHaveBeenNthCalledWith(1, {
$or: [{ openidId: 'openid_123' }],
});
expect(mockFindUser).toHaveBeenNthCalledWith(2, { email: 'user@example.com' });
expect(result).toEqual({
user: mockUser,
error: null,
migration: false,
});
});
it('should return null user when email is not found', async () => {
mockFindUser
.mockResolvedValueOnce(null) // Primary condition fails
.mockResolvedValueOnce(null); // Email search fails
const result = await findOpenIDUser({
openidId: 'openid_123',
findUser: mockFindUser,
email: 'user@example.com',
});
expect(mockFindUser).toHaveBeenCalledTimes(2);
expect(result).toEqual({
user: null,
error: null,
migration: false,
});
});
it('should not search by email if not provided', async () => {
mockFindUser.mockResolvedValueOnce(null);
const result = await findOpenIDUser({
openidId: 'openid_123',
findUser: mockFindUser,
});
expect(mockFindUser).toHaveBeenCalledTimes(1);
expect(mockFindUser).toHaveBeenCalledWith({
$or: [{ openidId: 'openid_123' }],
});
expect(result).toEqual({
user: null,
error: null,
migration: false,
});
});
});
describe('Provider conflict handling', () => {
it('should return error when user has different provider', async () => {
const mockUser: IUser = {
_id: newId(),
provider: 'google',
email: 'user@example.com',
username: 'testuser',
} as IUser;
mockFindUser
.mockResolvedValueOnce(null) // Primary condition fails
.mockResolvedValueOnce(mockUser); // Email search finds user with different provider
const result = await findOpenIDUser({
openidId: 'openid_123',
findUser: mockFindUser,
email: 'user@example.com',
});
expect(result).toEqual({
user: null,
error: ErrorTypes.AUTH_FAILED,
migration: false,
});
});
it('should reject email fallback when existing openidId does not match token sub', async () => {
const mockUser: IUser = {
_id: newId(),
provider: 'openid',
openidId: 'openid_456',
email: 'user@example.com',
username: 'testuser',
} as IUser;
mockFindUser.mockResolvedValueOnce(null).mockResolvedValueOnce(mockUser);
const result = await findOpenIDUser({
openidId: 'openid_123',
findUser: mockFindUser,
email: 'user@example.com',
});
expect(result).toEqual({
user: null,
error: ErrorTypes.AUTH_FAILED,
migration: false,
});
});
it('should allow email fallback when existing openidId matches token sub', async () => {
const mockUser: IUser = {
_id: newId(),
provider: 'openid',
openidId: 'openid_123',
email: 'user@example.com',
username: 'testuser',
} as IUser;
mockFindUser.mockResolvedValueOnce(null).mockResolvedValueOnce(mockUser);
const result = await findOpenIDUser({
openidId: 'openid_123',
findUser: mockFindUser,
email: 'user@example.com',
});
expect(result).toEqual({
user: mockUser,
error: null,
migration: false,
});
});
});
describe('User migration scenarios', () => {
it('should prepare user for migration when email exists without openidId', async () => {
const mockUser: IUser = {
_id: newId(),
email: 'user@example.com',
username: 'testuser',
// No provider and no openidId - needs migration
} as IUser;
mockFindUser
.mockResolvedValueOnce(null) // Primary condition fails
.mockResolvedValueOnce(mockUser); // Email search finds user without openidId
const result = await findOpenIDUser({
openidId: 'openid_123',
findUser: mockFindUser,
email: 'user@example.com',
});
expect(result).toEqual({
user: {
...mockUser,
provider: 'openid',
openidId: 'openid_123',
},
error: null,
migration: true,
});
});
it('should reject when user already has a different openidId', async () => {
const mockUser: IUser = {
_id: newId(),
provider: 'openid',
openidId: 'existing_openid',
email: 'user@example.com',
username: 'testuser',
} as IUser;
mockFindUser.mockResolvedValueOnce(null).mockResolvedValueOnce(mockUser);
const result = await findOpenIDUser({
openidId: 'openid_123',
findUser: mockFindUser,
email: 'user@example.com',
});
expect(result).toEqual({
user: null,
error: ErrorTypes.AUTH_FAILED,
migration: false,
});
});
it('should reject when user has no provider but a different openidId', async () => {
const mockUser: IUser = {
_id: newId(),
openidId: 'existing_openid',
email: 'user@example.com',
username: 'testuser',
// No provider field — tests a different branch than openid-provider mismatch
} as IUser;
mockFindUser.mockResolvedValueOnce(null).mockResolvedValueOnce(mockUser);
const result = await findOpenIDUser({
openidId: 'openid_123',
findUser: mockFindUser,
email: 'user@example.com',
});
expect(result).toEqual({
user: null,
error: ErrorTypes.AUTH_FAILED,
migration: false,
});
});
});
describe('Custom strategy names', () => {
it('should use custom strategy name in logs', async () => {
const loggerWarn = logger.warn as jest.Mock;
loggerWarn.mockClear();
mockFindUser.mockResolvedValueOnce(null).mockResolvedValueOnce(null);
await findOpenIDUser({
openidId: 'openid_123',
findUser: mockFindUser,
email: 'user@example.com',
strategyName: 'customStrategy',
});
expect(loggerWarn).toHaveBeenCalledWith(expect.stringContaining('[customStrategy]'));
});
it('should default to openid strategy name', async () => {
const loggerWarn = logger.warn as jest.Mock;
loggerWarn.mockClear();
mockFindUser.mockResolvedValueOnce(null).mockResolvedValueOnce(null);
await findOpenIDUser({
openidId: 'openid_123',
findUser: mockFindUser,
email: 'user@example.com',
});
expect(loggerWarn).toHaveBeenCalledWith(expect.stringContaining('[openid]'));
});
});
describe('Edge cases', () => {
it('should handle empty string openidId', async () => {
mockFindUser.mockResolvedValueOnce(null);
const result = await findOpenIDUser({
openidId: '',
findUser: mockFindUser,
});
expect(mockFindUser).not.toHaveBeenCalled();
expect(result).toEqual({
user: null,
error: null,
migration: false,
});
});
it('should handle empty string idOnTheSource', async () => {
mockFindUser.mockResolvedValueOnce(null);
const result = await findOpenIDUser({
openidId: 'openid_123',
findUser: mockFindUser,
idOnTheSource: '',
});
expect(mockFindUser).toHaveBeenCalledWith({
$or: [{ openidId: 'openid_123' }],
});
expect(result).toEqual({
user: null,
error: null,
migration: false,
});
});
it('should handle both openidId and idOnTheSource as empty strings', async () => {
await findOpenIDUser({
openidId: '',
findUser: mockFindUser,
idOnTheSource: '',
email: 'user@example.com',
});
// Should skip primary search and go directly to email search
expect(mockFindUser).toHaveBeenCalledTimes(1);
expect(mockFindUser).toHaveBeenCalledWith({ email: 'user@example.com' });
});
it('should pass email to findUser for case-insensitive lookup (findUser handles normalization)', async () => {
const mockUser: IUser = {
_id: newId(),
provider: 'openid',
openidId: 'openid_123',
email: 'user@example.com',
username: 'testuser',
} as IUser;
mockFindUser.mockResolvedValueOnce(null).mockResolvedValueOnce(mockUser);
const result = await findOpenIDUser({
openidId: 'openid_123',
findUser: mockFindUser,
email: 'User@Example.COM',
});
expect(mockFindUser).toHaveBeenNthCalledWith(2, { email: 'User@Example.COM' });
expect(result).toEqual({
user: mockUser,
error: null,
migration: false,
});
});
it('should handle findUser throwing an error', async () => {
mockFindUser.mockRejectedValueOnce(new Error('Database error'));
await expect(
findOpenIDUser({
openidId: 'openid_123',
findUser: mockFindUser,
}),
).rejects.toThrow('Database error');
});
it('should reject email fallback when openidId is empty and user has a stored openidId', async () => {
const mockUser: IUser = {
_id: newId(),
provider: 'openid',
openidId: 'existing-real-id',
email: 'user@example.com',
username: 'testuser',
} as IUser;
mockFindUser.mockResolvedValueOnce(mockUser);
const result = await findOpenIDUser({
openidId: '',
findUser: mockFindUser,
email: 'user@example.com',
});
expect(mockFindUser).toHaveBeenCalledTimes(1);
expect(mockFindUser).toHaveBeenCalledWith({ email: 'user@example.com' });
expect(result).toEqual({
user: null,
error: ErrorTypes.AUTH_FAILED,
migration: false,
});
});
});
});