Files
chat/api/server/controllers/UserController.spec.js
zooqueen 58ecc28b0a the credentials lane runs the files it names
Jest reads its arguments as patterns, so a listed spec that is not there
contributes nothing and the run is green on what is left. This lane named ten
files and ran six, reporting 29 tests where its own note claimed seventy-five,
and nothing anywhere said so.

Three of the four were right to go: 090724e9a6 handed two-factor, verification,
reset and session issuance to IAM and deleted AuthService, twoFactorService and
AuthController with their specs. Those names are dropped and the note says why.

UserController did not go. It is still the projection boundary for
GET /v1/chat/user and it had simply lost its cover, so it is written again --
the credentials out of the answer, the welcome flag still in it, a guest served
without reading the store, and the store's own words kept out of a 500. Both
halves mutation-tested: dropping the publicUser projection and returning
error.message each turn exactly one assertion red.

The names are checked before they run, so the lane fails loudly rather than
shrinking in silence.
2026-08-17 16:43:16 -07:00

155 lines
5.4 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* What a user document may carry out of this process, and what records that it
* has been greeted.
*
* The file this replaces was deleted in 70196c49a1 along with `AuthService`,
* `twoFactorService` and `AuthController` — IAM owns those now, so their specs
* went with their subjects. `UserController` did not go: it is still the
* projection boundary for `GET /v1/chat/user`, and the `credentials` lane in
* hanzo.yml has been naming a file that was not there ever since. Jest reads
* those arguments as patterns, so the lane quietly ran six files while claiming
* ten and stayed green throughout.
*/
const mockFindByIdAndUpdate = jest.fn();
const mockLoggerError = jest.fn();
jest.mock('@hanzochat/data-schemas', () => ({
logger: { error: mockLoggerError, warn: jest.fn(), debug: jest.fn(), info: jest.fn() },
webSearchKeys: [],
}));
jest.mock('@hanzochat/data-provider', () => ({
Tools: {},
CacheKeys: {},
Constants: {},
FileSources: { s3: 's3' },
}));
jest.mock('@hanzochat/api', () => ({
MCPOAuthHandler: {},
MCPTokenStorage: {},
normalizeHttpError: jest.fn(),
extractWebSearchEnvVars: jest.fn(),
resolveTenantBearer: jest.fn(),
}));
jest.mock('~/models', () => ({ updateUser: jest.fn() }), { virtual: true });
jest.mock('~/db/models', () => ({ User: { findByIdAndUpdate: mockFindByIdAndUpdate } }), {
virtual: true,
});
jest.mock('~/server/services/PluginService', () => ({}), { virtual: true });
jest.mock('~/config', () => ({}), { virtual: true });
jest.mock('~/server/services/Config/getCachedTools', () => ({}), { virtual: true });
jest.mock('~/server/services/Files/S3/crud', () => ({ needsRefresh: () => false }), {
virtual: true,
});
jest.mock('~/server/services/Files/process', () => ({}), { virtual: true });
jest.mock('~/server/services/Config', () => ({ getAppConfig: async () => ({}) }), { virtual: true });
jest.mock(
'~/server/services/guestConfig',
() => ({ buildGuestUser: () => ({ id: 'guest', guest: true }) }),
{ virtual: true },
);
jest.mock('~/server/services/iamUser', () => ({ pictureFromUserinfo: jest.fn() }), {
virtual: true,
});
jest.mock('~/models/ToolCall', () => ({}), { virtual: true });
jest.mock('~/models/Prompt', () => ({}), { virtual: true });
jest.mock('~/models/Agent', () => ({}), { virtual: true });
jest.mock('~/cache', () => ({ getLogStores: () => ({}) }), { virtual: true });
const { getUserController, tourController } = require('./UserController');
/** The res double: records the status and the body, nothing else. */
const reply = () => {
const res = {};
res.status = jest.fn(() => res);
res.send = jest.fn(() => res);
res.json = jest.fn(() => res);
return res;
};
/** Everything a stored user carries, including what must never leave. */
const stored = {
_id: 'user-1',
id: 'user-1',
email: '[email protected]',
name: 'Newcomer',
role: 'USER',
toured: false,
refreshToken: 'REFRESH-SECRET',
totpSecret: 'TOTP-SECRET',
backupCodes: ['CODE-SECRET'],
password: 'HASH',
};
beforeEach(() => jest.clearAllMocks());
describe('what a user document carries out of the process', () => {
it('projects the credentials out of the answer', async () => {
const res = reply();
await getUserController({ user: { ...stored } }, res);
const [sent] = res.send.mock.calls[0];
for (const secret of ['refreshToken', 'totpSecret', 'backupCodes', 'password']) {
expect(sent).not.toHaveProperty(secret);
}
expect(JSON.stringify(sent)).not.toContain('SECRET');
});
it('carries the welcome flag, which the client needs to decide', async () => {
const res = reply();
await getUserController({ user: { ...stored } }, res);
expect(res.send.mock.calls[0][0]).toMatchObject({ toured: false });
});
it('answers a guest without reading the store', async () => {
const res = reply();
await getUserController({ user: { guest: true } }, res);
expect(res.status).toHaveBeenCalledWith(200);
expect(res.send.mock.calls[0][0]).toMatchObject({ guest: true });
expect(mockFindByIdAndUpdate).not.toHaveBeenCalled();
});
});
describe('recording that the welcome card was seen', () => {
it('writes the flag for the caller and nobody else', async () => {
mockFindByIdAndUpdate.mockResolvedValue({ _id: 'user-1', toured: true });
const res = reply();
await tourController({ user: { id: 'user-1' } }, res);
expect(mockFindByIdAndUpdate).toHaveBeenCalledWith(
'user-1',
{ toured: true },
{ new: true },
);
expect(res.status).toHaveBeenCalledWith(200);
expect(res.json).toHaveBeenCalledWith({ toured: true });
});
it('refuses a caller with no record', async () => {
mockFindByIdAndUpdate.mockResolvedValue(null);
const res = reply();
await tourController({ user: { id: 'ghost' } }, res);
expect(res.status).toHaveBeenCalledWith(404);
});
/**
* A failing write answers 500 and says nothing else. Handing `error.message`
* to the browser is how a store's internals become a response body.
*/
it('keeps the store’s own words out of the answer', async () => {
mockFindByIdAndUpdate.mockRejectedValue(new Error('SQLITE_BUSY: database is locked'));
const res = reply();
await tourController({ user: { id: 'user-1' } }, res);
expect(res.status).toHaveBeenCalledWith(500);
expect(JSON.stringify(res.json.mock.calls[0][0])).not.toContain('SQLITE_BUSY');
expect(mockLoggerError).toHaveBeenCalled();
});
});