mirror of
https://github.com/HeyPuter/puter.git
synced 2026-10-22 03:21:27 +00:00
Backend Tests / changes (push) Canceled after 0s
Backend Tests / typecheck (push) Canceled after 0s
Backend Tests / test shard 1/4 (push) Canceled after 0s
Backend Tests / test shard 2/4 (push) Canceled after 0s
Backend Tests / test shard 3/4 (push) Canceled after 0s
Backend Tests / test shard 4/4 (push) Canceled after 0s
Backend Tests / test (main) (push) Canceled after 0s
Backend Tests / coverage (push) Canceled after 0s
Maintain Release Merge PR / update-release-pr (push) Canceled after 0s
Notify HeyPuter / notify (push) Canceled after 0s
release-please / release-please (push) Canceled after 0s
Pipeline - CORS preflights send Access-Control-Max-Age: browsers stop re-preflighting authed calls every 5 s. - `req.ua` is parsed on first read instead of on every request. - JSON `rawBody` reuses the parsed buffer instead of copying it. - `emitAndWait` returns early when nothing listens: no span or closure per unlistened driver call. - SQLBatcher sends a lone query on the next tick while fewer than 8 flushes are in flight, and waits `maxTimeInQueue` as before otherwise. Lone reads skip the 10/30 ms wait; batching under load is unchanged. Auth and permissions - Token-to-actor auth starts the user, session and app lookups together and checks them in the same order: fewer sequential Redis round trips per authed request. - get-user-app-token and check-app reuse the resolved app row. getOrCreateApp skips the re-select after its own insert and doesn't wait on the cache write for replica rows. - getOrCreateApp reads the race winner from the primary when its insert loses, so it no longer hands out a session that was never written. - whoami runs the email check and the OIDC/taskbar/team lookups in parallel; key order unchanged. - OIDC providers are memoized once every discovery succeeds, with one discovery fetch in flight per URL, and the Apple key is parsed once. - gui-token and sync-cookie use `req.actor.user` instead of re-reading the user. - listSessions batches app lookups. App icon probes run together, and the original-icon probe skips the lineage fallback. - Default-granted permissions are answered from config before the scan cache, and exploded options are deduped: one less Redis read per /drivers/call. - ACL resolves ancestors lazily (owners and full-access tokens skip them) and scans the node first, then the ancestors concurrently on a miss. Filesystem - Missing-path lineage lookups run 10 at a time: batch-upload path resolution drops from about 1.3 s to about 40 ms per 100 new files. - MySQL single-file inserts return the row built from the written values, falling back to the primary read: one less primary round trip per new file. - Batch thumbnails are stored with one UPDATE per chunk (200 rows or 4M chars), with a per-row fallback. - storage.quota.bonus listeners run concurrently next to the usage SUM, and the payload carries `userUuid`: quota checks and /df no longer serialize the listener reads. - readdir cache backfill is one pipeline instead of 3xN SETEX. - Profile reads skip the lineage fallback; the own profile is read alongside the user lookup. - Legacy /stat runs is_empty (new `hasChildrenByParentUid`), owner and thumbnail concurrently. - Downloads and the video proxy pipe through `pipeToResponse`: aborts release the storage stream at once with no extra 'close' listeners, and the video proxy no longer crashes on a mid-stream upstream error. - mkdir looks up target and parent together. - Upload completion reuses the session from the access check (status is still re-read before completing), and multipart signing reads it once. - Moving a shared file to Trash skips two lookups that always returned the entry itself. Drivers and events - Subscription resolvers run concurrently and are folded in registration order. - SubdomainDriver and WorkerDriver overlap independent lookups. - The events "anyone subscribed?" check is one Redis pipeline when the region is warm. - Handler publish overlaps the count with the size check. - Broadcast flushes peers concurrently over a keep-alive agent, so a slow peer no longer delays cross-region invalidations to the others. Sites, share and AI - Hosted sites resolve the owner first, then the app and root together. Site config loads only on 404s. The hosted-app query selects only the columns it uses and moves into AppStore. - shareFlags asks about the plan only when an item is shared by link only. - Share listings run the count, teams and plan reads alongside their page query. - Claude attachment cleanup no longer blocks the response. - New util `awaitLater` for promises started early and awaited later.
610 lines
22 KiB
TypeScript
610 lines
22 KiB
TypeScript
import type { Request, Response } from 'express';
|
||
import { v4 as uuidv4 } from 'uuid';
|
||
import {
|
||
afterAll,
|
||
beforeAll,
|
||
describe,
|
||
expect,
|
||
it,
|
||
vi,
|
||
} from 'vitest';
|
||
import { makeActor, type Actor } from '../src/backend/core/actor.ts';
|
||
import { runWithContext } from '../src/backend/core/context.ts';
|
||
import { configContainer } from '../src/backend/exports.ts';
|
||
import { PuterServer } from '../src/backend/server.ts';
|
||
import { setupTestServer } from '../src/backend/testUtil.ts';
|
||
import { handleWhoami } from './whoami.ts';
|
||
|
||
interface CapturedResponse {
|
||
statusCode: number;
|
||
body: unknown;
|
||
}
|
||
|
||
const makeReq = (query: Record<string, unknown> = {}): Request =>
|
||
({ query }) as unknown as Request;
|
||
|
||
const makeRes = () => {
|
||
const captured: CapturedResponse = { statusCode: 200, body: undefined };
|
||
const res = {
|
||
json: vi.fn((value: unknown) => {
|
||
captured.body = value;
|
||
return res;
|
||
}),
|
||
status: vi.fn((code: number) => {
|
||
captured.statusCode = code;
|
||
return res;
|
||
}),
|
||
};
|
||
return { res: res as unknown as Response, captured };
|
||
};
|
||
|
||
let server: PuterServer;
|
||
|
||
beforeAll(async () => {
|
||
server = await setupTestServer({
|
||
// Feature flag allowlist is enforced in the handler. We seed
|
||
// both an allow-listed flag and an internal flag to verify the
|
||
// internal one never reaches the response.
|
||
feature_flags: {
|
||
create_shortcut: true,
|
||
payment_bypass: true,
|
||
},
|
||
teams_enabled: true,
|
||
} as never);
|
||
});
|
||
|
||
afterAll(async () => {
|
||
await server?.shutdown();
|
||
});
|
||
|
||
const seedUser = async () => {
|
||
const slug = Math.random().toString(36).slice(2, 8);
|
||
return server.stores.user.create({
|
||
username: `wuser_${slug}`,
|
||
uuid: uuidv4(),
|
||
password: 'hashedpw',
|
||
email: `${slug}@example.com`,
|
||
});
|
||
};
|
||
|
||
describe('whoami extension — handleWhoami', () => {
|
||
// The sidebar label needs this at boot, which is why it rides whoami
|
||
// rather than a call of its own.
|
||
describe('the team an account belongs to', () => {
|
||
// A seat is created, never adopted, so it must have no password.
|
||
const seedSeat = async () => {
|
||
const slug = Math.random().toString(36).slice(2, 8);
|
||
return server.stores.user.create({
|
||
username: `wseat_${slug}`,
|
||
uuid: uuidv4(),
|
||
password: null,
|
||
email: null,
|
||
});
|
||
};
|
||
|
||
const seatOf = async (teamName: string) => {
|
||
const owner = await seedUser();
|
||
const seat = await seedSeat();
|
||
const team = await server.stores.team.create({
|
||
ownerUserId: owner.id as number,
|
||
name: teamName,
|
||
handle: `wt-${Math.random().toString(36).slice(2, 9)}`,
|
||
});
|
||
await server.stores.team.addMember(team.uid, seat.id as number, {
|
||
orgOwned: true,
|
||
});
|
||
return { seat, team };
|
||
};
|
||
|
||
it('names the team for a seat', async () => {
|
||
const { seat, team } = await seatOf('Acme Corp');
|
||
const { res, captured } = makeRes();
|
||
|
||
await runWithContext(
|
||
{ actor: { user: { uuid: seat.uuid, id: seat.id as number } } },
|
||
() => handleWhoami(makeReq(), res),
|
||
);
|
||
|
||
expect((captured.body as { team?: unknown }).team).toEqual({
|
||
uid: team.uid,
|
||
name: 'Acme Corp',
|
||
});
|
||
});
|
||
|
||
it('says nothing for an account that is not a seat', async () => {
|
||
const user = await seedUser();
|
||
const { res, captured } = makeRes();
|
||
|
||
await runWithContext(
|
||
{ actor: { user: { uuid: user.uuid, id: user.id as number } } },
|
||
() => handleWhoami(makeReq(), res),
|
||
);
|
||
|
||
expect(captured.body).not.toHaveProperty('team');
|
||
});
|
||
|
||
it('withholds it from an app actor', async () => {
|
||
// Same class as the phone number: a seat's employer is not an
|
||
// app's business.
|
||
const { seat } = await seatOf('Acme Corp');
|
||
const { res, captured } = makeRes();
|
||
|
||
await runWithContext(
|
||
{
|
||
actor: makeActor({
|
||
user: { uuid: seat.uuid, id: seat.id as number },
|
||
app: { uid: 'app-1' },
|
||
}),
|
||
},
|
||
() => handleWhoami(makeReq(), res),
|
||
);
|
||
|
||
expect(captured.body).not.toHaveProperty('team');
|
||
});
|
||
});
|
||
|
||
it('returns 401 when no actor is on the context', async () => {
|
||
const { res, captured } = makeRes();
|
||
|
||
await runWithContext({ actor: undefined }, () =>
|
||
handleWhoami(makeReq(), res),
|
||
);
|
||
|
||
expect(captured.statusCode).toBe(401);
|
||
expect(captured.body).toEqual({ error: 'Authentication required' });
|
||
});
|
||
|
||
it('returns 404 when the actor’s user no longer exists', async () => {
|
||
const { res, captured } = makeRes();
|
||
|
||
await runWithContext(
|
||
{
|
||
actor: {
|
||
user: { uuid: 'ghost-uuid', id: 99_999_999 },
|
||
},
|
||
},
|
||
() => handleWhoami(makeReq(), res),
|
||
);
|
||
|
||
expect(captured.statusCode).toBe(404);
|
||
expect(captured.body).toEqual({ error: 'User not found' });
|
||
});
|
||
|
||
describe("an app's view of the email", () => {
|
||
const appActorFor = async (user: { uuid: string; id: unknown }) => {
|
||
const app = await (
|
||
server.stores.app.create as unknown as (
|
||
fields: Record<string, unknown>,
|
||
opts: { ownerUserId: number },
|
||
) => Promise<{ uid: string; id: number }>
|
||
)(
|
||
{
|
||
name: `we-${uuidv4()}`,
|
||
title: 'Email reader',
|
||
index_url: `https://${uuidv4()}.example/`,
|
||
},
|
||
{ ownerUserId: user.id as number },
|
||
);
|
||
return {
|
||
app,
|
||
actor: makeActor({
|
||
user: { uuid: user.uuid, id: user.id as number },
|
||
app: { uid: app.uid, id: app.id },
|
||
}),
|
||
};
|
||
};
|
||
|
||
const whoamiAs = async (actor: Actor) => {
|
||
const { res, captured } = makeRes();
|
||
await runWithContext({ actor }, () =>
|
||
handleWhoami(makeReq(), res),
|
||
);
|
||
return captured;
|
||
};
|
||
|
||
it('is withheld until the user grants it, then shown', async () => {
|
||
const user = await seedUser();
|
||
const { app, actor } = await appActorFor(user);
|
||
|
||
const before = (await whoamiAs(actor)).body as Record<
|
||
string,
|
||
unknown
|
||
>;
|
||
expect(before).not.toHaveProperty('email');
|
||
expect(before).not.toHaveProperty('unconfirmed_email');
|
||
|
||
await server.services.permission.grantUserAppPermission(
|
||
makeActor({ user: { uuid: user.uuid, id: user.id as number } }),
|
||
app.uid,
|
||
`user:${user.uuid}:email:read`,
|
||
);
|
||
const after = (await whoamiAs(actor)).body as Record<
|
||
string,
|
||
unknown
|
||
>;
|
||
expect(after.email).toBe(user.email);
|
||
expect(after.unconfirmed_email).toBe(user.email);
|
||
});
|
||
|
||
it('answers 404 for a missing account even when the check fails', async () => {
|
||
const check = vi
|
||
.spyOn(server.services.permission, 'check')
|
||
.mockRejectedValue(new Error('scan failed'));
|
||
try {
|
||
const captured = await whoamiAs(
|
||
makeActor({
|
||
user: { uuid: 'ghost-uuid', id: 99_999_998 },
|
||
app: { uid: 'app-ghost', id: 1 },
|
||
}),
|
||
);
|
||
expect(captured.statusCode).toBe(404);
|
||
expect(captured.body).toEqual({ error: 'User not found' });
|
||
} finally {
|
||
check.mockRestore();
|
||
}
|
||
});
|
||
});
|
||
|
||
it('returns full user details for a user actor', async () => {
|
||
const user = await seedUser();
|
||
const { res, captured } = makeRes();
|
||
|
||
await runWithContext(
|
||
{ actor: { user: { uuid: user.uuid, id: user.id as number } } },
|
||
() => handleWhoami(makeReq(), res),
|
||
);
|
||
|
||
const body = captured.body as Record<string, unknown>;
|
||
expect(body.username).toBe(user.username);
|
||
expect(body.uuid).toBe(user.uuid);
|
||
expect(body.email).toBe(user.email);
|
||
expect(body.is_temp).toBe(false);
|
||
expect(body.oidc_only).toBe(false);
|
||
// is_user_token is present (true) for user actors.
|
||
expect(body.is_user_token).toBe(true);
|
||
// Account creation time, in unix seconds.
|
||
expect(typeof body.created_ts).toBe('number');
|
||
expect(body.created_ts).toBeGreaterThan(0);
|
||
// `directories` is only sent to user actors — confirm it’s present.
|
||
expect(body.directories).toBeDefined();
|
||
// taskbar_items is only sent to user actors.
|
||
expect(body).toHaveProperty('taskbar_items');
|
||
});
|
||
|
||
it('reports the SMS-to-card fallback only once a send has opened it', async () => {
|
||
const user = await server.stores.user.create({
|
||
username: `wuser_${Math.random().toString(36).slice(2, 8)}`,
|
||
uuid: uuidv4(),
|
||
password: 'hashedpw',
|
||
email: `${Math.random().toString(36).slice(2, 8)}@example.com`,
|
||
requires_phone_verification: true,
|
||
} as never);
|
||
const prev = configContainer.phone_verification_card_fallback;
|
||
configContainer.phone_verification_card_fallback = {
|
||
enabled: true,
|
||
} as never;
|
||
try {
|
||
const before = makeRes();
|
||
await runWithContext(
|
||
{ actor: { user: { uuid: user.uuid, id: user.id as number } } },
|
||
() => handleWhoami(makeReq(), before.res),
|
||
);
|
||
// Phone-gated, but the user has attempts left — no offer yet.
|
||
expect(
|
||
before.captured.body as Record<string, unknown>,
|
||
).toMatchObject({ card_fallback_available: false });
|
||
|
||
// Exhausting SMS attempts stamps this flag; whoami is then the only
|
||
// thing that can still tell a reloading GUI about the offer, since
|
||
// further sends are rejected by the route's own rate limit.
|
||
await server.stores.kv.set({
|
||
key: `card-fallback-open:${user.id}`,
|
||
value: true,
|
||
});
|
||
|
||
const after = makeRes();
|
||
await runWithContext(
|
||
{ actor: { user: { uuid: user.uuid, id: user.id as number } } },
|
||
() => handleWhoami(makeReq(), after.res),
|
||
);
|
||
expect(
|
||
after.captured.body as Record<string, unknown>,
|
||
).toMatchObject({ card_fallback_available: true });
|
||
} finally {
|
||
configContainer.phone_verification_card_fallback = prev;
|
||
}
|
||
});
|
||
|
||
it('only forwards allow-listed feature flags', async () => {
|
||
const user = await seedUser();
|
||
const { res, captured } = makeRes();
|
||
|
||
await runWithContext(
|
||
{ actor: { user: { uuid: user.uuid, id: user.id as number } } },
|
||
() => handleWhoami(makeReq(), res),
|
||
);
|
||
|
||
const flags = (captured.body as Record<string, unknown>)
|
||
.feature_flags as Record<string, boolean>;
|
||
// Allowed flag is forwarded as a coerced boolean.
|
||
expect(flags.create_shortcut).toBe(true);
|
||
// Internal flag must never leak.
|
||
expect(flags.payment_bypass).toBeUndefined();
|
||
});
|
||
|
||
it('strips desktop_bg_*, created_ts and human_readable_age fields for app actors', async () => {
|
||
const user = await seedUser();
|
||
const { res, captured } = makeRes();
|
||
|
||
await runWithContext(
|
||
{
|
||
actor: makeActor({
|
||
user: { uuid: user.uuid, id: user.id as number },
|
||
app: { uid: 'app-test-actor' },
|
||
}),
|
||
},
|
||
() => handleWhoami(makeReq(), res),
|
||
);
|
||
|
||
const body = captured.body as Record<string, unknown>;
|
||
expect(body.app_name).toBe('app-test-actor');
|
||
// is_user_token is stripped for app actors.
|
||
expect(body.is_user_token).toBeUndefined();
|
||
expect(body.desktop_bg_url).toBeUndefined();
|
||
expect(body.desktop_bg_color).toBeUndefined();
|
||
expect(body.desktop_bg_fit).toBeUndefined();
|
||
expect(body.human_readable_age).toBeUndefined();
|
||
// Account age, in either form, is not exposed to apps.
|
||
expect(body.created_ts).toBeUndefined();
|
||
// Directories are user-only.
|
||
expect(body.directories).toBeUndefined();
|
||
});
|
||
|
||
describe('fields added by whoami.details listeners', () => {
|
||
const addAccountDetails = (
|
||
_key: unknown,
|
||
event: { details: Record<string, unknown> },
|
||
) => {
|
||
Object.assign(event.details, {
|
||
subscribed: true,
|
||
paid_storage: 1024,
|
||
subscription: { tier: 'pro', status: 'trialing' },
|
||
accountEligible: true,
|
||
});
|
||
};
|
||
|
||
const whoamiWithListener = async (
|
||
actor: Actor,
|
||
listener: typeof addAccountDetails = addAccountDetails,
|
||
) => {
|
||
const { res, captured } = makeRes();
|
||
server.clients.event.on('whoami.details', listener);
|
||
try {
|
||
await runWithContext({ actor }, () =>
|
||
handleWhoami(makeReq(), res),
|
||
);
|
||
} finally {
|
||
server.clients.event.off('whoami.details', listener);
|
||
}
|
||
return captured.body as Record<string, unknown>;
|
||
};
|
||
|
||
it('reach an app actor only as the documented subscribed / paid_storage', async () => {
|
||
const user = await seedUser();
|
||
const body = await whoamiWithListener(
|
||
makeActor({
|
||
user: { uuid: user.uuid, id: user.id as number },
|
||
app: { uid: 'app-test-actor' },
|
||
}),
|
||
);
|
||
|
||
expect(body.subscribed).toBe(true);
|
||
expect(body.paid_storage).toBe(1024);
|
||
expect(body).not.toHaveProperty('subscription');
|
||
expect(body).not.toHaveProperty('accountEligible');
|
||
// Fields the handler built itself still ship.
|
||
expect(body.username).toBe(user.username);
|
||
expect(body.app_name).toBe('app-test-actor');
|
||
});
|
||
|
||
it('are withheld from an app reached through an access token it issued', async () => {
|
||
const user = await seedUser();
|
||
const owner = { uuid: user.uuid, id: user.id as number };
|
||
const body = await whoamiWithListener(
|
||
makeActor({
|
||
user: owner,
|
||
accessToken: {
|
||
uid: 'tok-1',
|
||
issuer: makeActor({
|
||
user: owner,
|
||
app: { uid: 'app-issuer' },
|
||
}),
|
||
},
|
||
}),
|
||
);
|
||
|
||
expect(body.subscribed).toBe(true);
|
||
expect(body).not.toHaveProperty('subscription');
|
||
expect(body).not.toHaveProperty('accountEligible');
|
||
});
|
||
|
||
it('all reach a user actor', async () => {
|
||
const user = await seedUser();
|
||
const body = await whoamiWithListener(
|
||
makeActor({ user: { uuid: user.uuid, id: user.id as number } }),
|
||
);
|
||
|
||
expect(body).toMatchObject({
|
||
subscribed: true,
|
||
paid_storage: 1024,
|
||
subscription: { tier: 'pro', status: 'trialing' },
|
||
accountEligible: true,
|
||
});
|
||
});
|
||
|
||
it('cannot add taskbar_items for an app actor, which core omits', async () => {
|
||
const user = await seedUser();
|
||
const body = await whoamiWithListener(
|
||
makeActor({
|
||
user: { uuid: user.uuid, id: user.id as number },
|
||
app: { uid: 'app-test-actor' },
|
||
}),
|
||
(_key, event) => {
|
||
event.details.taskbar_items = [{ name: 'injected' }];
|
||
},
|
||
);
|
||
|
||
expect(body).not.toHaveProperty('taskbar_items');
|
||
});
|
||
});
|
||
|
||
it('redacts tmp_password from metadata for user actors', async () => {
|
||
const user = await seedUser();
|
||
await server.stores.user.updateMetadata(user.id as number, {
|
||
tmp_password: 'bootstrap-secret',
|
||
hasDevAccountAccess: true,
|
||
});
|
||
|
||
const { res, captured } = makeRes();
|
||
await runWithContext(
|
||
{ actor: { user: { uuid: user.uuid, id: user.id as number } } },
|
||
() => handleWhoami(makeReq(), res),
|
||
);
|
||
|
||
const body = captured.body as Record<string, unknown>;
|
||
const metadata = body.metadata as Record<string, unknown>;
|
||
expect(metadata.tmp_password).toBeUndefined();
|
||
// Other metadata keys still reach the user's own client.
|
||
expect(metadata.hasDevAccountAccess).toBe(true);
|
||
expect(body.hasDevAccountAccess).toBe(true);
|
||
});
|
||
|
||
it('never sends user metadata to app actors', async () => {
|
||
const user = await seedUser();
|
||
await server.stores.user.updateMetadata(user.id as number, {
|
||
tmp_password: 'bootstrap-secret',
|
||
});
|
||
|
||
const { res, captured } = makeRes();
|
||
await runWithContext(
|
||
{
|
||
actor: makeActor({
|
||
user: { uuid: user.uuid, id: user.id as number },
|
||
app: { uid: 'app-test-actor' },
|
||
}),
|
||
},
|
||
() => handleWhoami(makeReq(), res),
|
||
);
|
||
|
||
const body = captured.body as Record<string, unknown>;
|
||
expect(body.metadata).toBeUndefined();
|
||
expect(JSON.stringify(body)).not.toContain('bootstrap-secret');
|
||
});
|
||
|
||
it('never exposes phone, card fingerprint or signup identity', async () => {
|
||
const user = await seedUser();
|
||
await server.stores.user.update(user.id as number, {
|
||
phone: '+15551234567',
|
||
card_fingerprint: 'fp_ABC123',
|
||
requires_phone_verification: false,
|
||
requires_card_verification: false,
|
||
});
|
||
// Guard against a vacuous assertion below: the columns really do hold
|
||
// the values we then expect never to see on the wire.
|
||
const stored = await server.stores.user.getById(user.id as number, {
|
||
cached: false,
|
||
force: true,
|
||
});
|
||
expect(stored?.phone).toBe('+15551234567');
|
||
expect(stored?.card_fingerprint).toBe('fp_ABC123');
|
||
|
||
for (const actor of [
|
||
makeActor({ user: { uuid: user.uuid, id: user.id as number } }),
|
||
makeActor({
|
||
user: { uuid: user.uuid, id: user.id as number },
|
||
app: { uid: 'app-test-actor' },
|
||
}),
|
||
]) {
|
||
const { res, captured } = makeRes();
|
||
await runWithContext({ actor }, () =>
|
||
handleWhoami(makeReq(), res),
|
||
);
|
||
|
||
const body = captured.body as Record<string, unknown>;
|
||
expect(body.phone).toBeUndefined();
|
||
expect(body.card_fingerprint).toBeUndefined();
|
||
expect(body.password).toBeUndefined();
|
||
expect(body.otp_secret).toBeUndefined();
|
||
expect(body.signup_ip).toBeUndefined();
|
||
// Not just absent as a key — the values must not appear anywhere
|
||
// in the payload (nested under metadata, taskbar items, …).
|
||
const serialized = JSON.stringify(body);
|
||
expect(serialized).not.toContain('5551234567');
|
||
expect(serialized).not.toContain('fp_ABC123');
|
||
// The verification flags, which the GUI acts on, still ship.
|
||
expect(body).toHaveProperty('requires_phone_verification');
|
||
expect(body).toHaveProperty('requires_card_verification');
|
||
}
|
||
});
|
||
|
||
it('scrubs sensitive keys added to metadata, and leaves the cached row intact', async () => {
|
||
const user = await seedUser();
|
||
await server.stores.user.updateMetadata(user.id as number, {
|
||
tmp_password: 'bootstrap-secret',
|
||
billing: { card_fingerprint: 'fp_NESTED', tier: 'pro' },
|
||
});
|
||
|
||
const { res, captured } = makeRes();
|
||
await runWithContext(
|
||
{ actor: { user: { uuid: user.uuid, id: user.id as number } } },
|
||
() => handleWhoami(makeReq(), res),
|
||
);
|
||
|
||
const body = captured.body as Record<string, unknown>;
|
||
const metadata = body.metadata as Record<string, unknown>;
|
||
expect(metadata.tmp_password).toBeUndefined();
|
||
// Nested sensitive keys are removed too; siblings survive.
|
||
expect(metadata.billing).toEqual({ tier: 'pro' });
|
||
expect(JSON.stringify(body)).not.toContain('fp_NESTED');
|
||
|
||
// The scrub works on a copy — server-side state is untouched.
|
||
const fresh = await server.stores.user.getById(user.id as number, {
|
||
cached: false,
|
||
force: true,
|
||
});
|
||
expect(fresh?.metadata?.tmp_password).toBe('bootstrap-secret');
|
||
expect(
|
||
(fresh?.metadata?.billing as Record<string, unknown>)
|
||
?.card_fingerprint,
|
||
).toBe('fp_NESTED');
|
||
});
|
||
|
||
it('marks the user as oidc_only when password is null', async () => {
|
||
const slug = Math.random().toString(36).slice(2, 8);
|
||
const oidcUser = await server.stores.user.create({
|
||
username: `oidc_${slug}`,
|
||
uuid: uuidv4(),
|
||
password: null,
|
||
email: `${slug}@oidc.test`,
|
||
});
|
||
|
||
const { res, captured } = makeRes();
|
||
await runWithContext(
|
||
{
|
||
actor: {
|
||
user: {
|
||
uuid: oidcUser.uuid,
|
||
id: oidcUser.id as number,
|
||
},
|
||
},
|
||
},
|
||
() => handleWhoami(makeReq(), res),
|
||
);
|
||
|
||
const body = captured.body as Record<string, unknown>;
|
||
expect(body.oidc_only).toBe(true);
|
||
// No email yet means temp account.
|
||
expect(body.is_temp).toBe(false);
|
||
});
|
||
});
|