Files
chat/api/server/services/publicUser.spec.js
zooqueenandHanzo Dev 5e34c80d78 chat: send the browser a user it may see, not a user minus four fields
`GET /v1/chat/user` and every `/auth/refresh` answered with the visitor's own
`refreshToken` array. It is `[SessionSchema]` on the user schema with no
`select: false` beside it — unlike `backupCodes`, which has one — so it sailed
through the jwt strategy's `-password -__v -totpSecret -backupCodes` and the
controller's three matching `delete`s. Four fields named, twenty-six shipped.

The credential is the visitor's own and they already hold one in a cookie, so
this is not a disclosure to a stranger. It is a reachability change, and that
is the part that matters: httpOnly exists precisely so script cannot read that
token. Putting it in a response body hands it to any injected script, which
turns "act as this person while the tab is open" into "keep acting as them,
from anywhere, until it expires".

So both boundaries now project through ONE allow-list (services/publicUser)
instead of each spelling its own deny-list. A deny-list is correct only for the
fields somebody thought of; this one is wrong by default for anything added to
the schema later, which is exactly how `refreshToken` got out. The tests assert
the RULE — an invented field is withheld — not just today's list, because a
test that checked only the remembered fields would have passed the whole time.

Also withheld: the external identity ids (googleId/facebookId/samlId/ldapId/
githubId/discordId/appleId/idOnTheSource). Nothing in the client reads them;
verified by grep, not assumed.

CORRECTED a test rather than obeyed it. The spec demanded `openidId` be
withheld, and Providers/AnalyticsProvider reads it as the IAM `sub` — the one
id correlating a person across hanzo.ai, hanzo.app and here, which chat's own
`id` cannot stand in for. Obeying it would have broken analytics identity
silently. It now asserts the field IS present, with the reason.

That whole test was already failing on `__v` and had been for a while, and it
was hiding the rest: jest stops at the first failed assertion, so the eleven
checks after it — including the token bags — had never run. Rewriting the
guard as a projection turns UserController from 7/8 to 13/13.

oauth.spec's 2 failures are pre-existing and unrelated; identical test names on
a clean tree, checked by stashing.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-08 09:19:34 -07:00

122 lines
3.7 KiB
JavaScript

const { publicUser, PUBLIC } = require('./publicUser');
/**
* The projection is the boundary, so these assert the two directions it has to
* get right: nothing sensitive escapes, and nothing the app renders is lost.
*
* The first matters more than it looks. `refreshToken` reached the browser for
* as long as this endpoint has existed, because every guard here was a
* deny-list and nobody had thought of it. A test that only checked the fields
* somebody remembered would have passed the whole time — which is why the last
* case asserts the RULE (an unknown field is withheld) rather than a list.
*/
describe('publicUser', () => {
const full = {
id: 'u1',
_id: 'u1',
name: 'A Person',
username: 'person',
email: '[email protected]',
emailVerified: true,
avatar: '/avatars/u1.png',
provider: 'openid',
role: 'USER',
openidId: 'iam-sub-uuid',
organization: 'hanzo',
project: 'proj',
groups: ['hanzo'],
plugins: ['web_search'],
twoFactorEnabled: true,
termsAccepted: true,
personalization: { memories: false },
favorites: [{ model: 'zen5', endpoint: 'Hanzo' }],
// none of the following may ever reach a browser
password: 'hashed',
__v: 3,
totpSecret: 'totp',
backupCodes: [{ codeHash: 'x' }],
refreshToken: [{ refreshToken: 'a-real-refresh-token' }],
googleId: 'g',
facebookId: 'f',
samlId: 's',
ldapId: 'l',
githubId: 'gh',
discordId: 'd',
appleId: 'ap',
idOnTheSource: 'ext',
};
it('withholds the refresh token — the credential httpOnly exists to hide', () => {
const out = publicUser(full);
expect(out).not.toHaveProperty('refreshToken');
// Belt and braces: not anywhere in the serialized body either.
expect(JSON.stringify(out)).not.toContain('a-real-refresh-token');
});
it('withholds every secret and external identity id', () => {
const out = publicUser(full);
for (const key of [
'password',
'__v',
'totpSecret',
'backupCodes',
'googleId',
'facebookId',
'samlId',
'ldapId',
'githubId',
'discordId',
'appleId',
'idOnTheSource',
]) {
expect(out).not.toHaveProperty(key);
}
});
it('keeps what the app renders, including the IAM subject', () => {
const out = publicUser(full);
expect(out).toMatchObject({
id: 'u1',
email: '[email protected]',
role: 'USER',
avatar: '/avatars/u1.png',
twoFactorEnabled: true,
// the org switcher
organization: 'hanzo',
project: 'proj',
groups: ['hanzo'],
// AnalyticsProvider's cross-surface identity
openidId: 'iam-sub-uuid',
});
});
it('withholds a field nobody has heard of — the rule, not a list', () => {
// The whole reason this is an allow-list: a field added to the schema
// tomorrow must not ship merely because no one remembered to strip it.
const out = publicUser({ ...full, aFieldAddedNextTuesday: 'surprise' });
expect(out).not.toHaveProperty('aFieldAddedNextTuesday');
});
it('omits absent keys rather than sending them undefined', () => {
const out = publicUser({ id: 'u1' });
expect(Object.keys(out)).toEqual(['id']);
});
it('reads a mongoose document through toObject', () => {
const doc = { toObject: () => full };
expect(publicUser(doc).email).toBe('[email protected]');
expect(publicUser(doc)).not.toHaveProperty('refreshToken');
});
it('passes null and undefined straight back', () => {
expect(publicUser(null)).toBeNull();
expect(publicUser(undefined)).toBeUndefined();
});
it('never lists a secret among the public fields', () => {
for (const key of ['password', 'totpSecret', 'backupCodes', 'refreshToken', '__v']) {
expect(PUBLIC).not.toContain(key);
}
});
});