Skip to content

Commit cbc3ffd

Browse files
committed
test(plugin-auth): type the audit sink spy so its call log is a real tuple (#8144)
`vi.fn(async () => undefined)` types `mock.calls` as `[][]` — a zero-length tuple — so every `calls[0][0]` in this file reached past the end of a tuple the type system believed was empty (TS2493 x3), and the dereference that followed was `possibly undefined` (TS18048 x3). Reading the argument back is the entire point of these cases, so the fix is to declare what the spy receives rather than to soften the read: the spy's implementation now names its parameter, and the call log is pulled through `recordedEvents` / `firstEvent`, which name the "never called" case instead of letting it surface as a TypeError. Pinning the element type to `AuthSessionAuditEventInput` also makes these assertions type-check against the real event surface instead of `any`: a renamed field now fails at compile time rather than quietly comparing `undefined` to `undefined`. One `(c: any[])` map goes away with it. Behaviour unchanged — 1113/1113 plugin-auth tests pass, same 20 cases. Why now: #8225 lowered this package's TEST_DEBT ceiling 131 -> 111 after this branch was cut, so these six errors stopped being slack and became a violation in the merge queue. Measured at 117 against the merged tree, 111 after this commit — exactly the ceiling, ledger untouched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PEVB6w7D7uCszR9Mw1BL73
1 parent ea2b923 commit cbc3ffd

1 file changed

Lines changed: 46 additions & 6 deletions

File tree

packages/plugins/plugin-auth/src/auth-session-audit.test.ts

Lines changed: 46 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,12 @@
1919

2020
import { describe, it, expect, vi } from 'vitest';
2121
import { AuthManager } from './auth-manager';
22-
import { loginEventFor, logoutEventFor, SIGN_OUT_PATH } from './auth-session-audit';
22+
import {
23+
loginEventFor,
24+
logoutEventFor,
25+
SIGN_OUT_PATH,
26+
type AuthSessionAuditEventInput,
27+
} from './auth-session-audit';
2328

2429
const SECRET = 'test-secret-at-least-32-chars-long';
2530

@@ -32,8 +37,43 @@ const SESSION = {
3237
userAgent: 'Mozilla/5.0 (probe)',
3338
};
3439

40+
/**
41+
* The structural minimum this file needs from the sink spy: a call log whose
42+
* entries are the one-argument tuple `recordAuthEvent` is declared with.
43+
*
44+
* Declared rather than inferred from `vi.fn()`'s return type, for two reasons.
45+
* A `vi.fn(async () => …)` whose implementation takes NO parameter types its
46+
* `mock.calls` as `[][]` — a zero-length tuple — so every `calls[0][0]` in this
47+
* file was reaching past the end of a tuple the type system believed was empty
48+
* (TS2493), and reading the argument back is the entire point of these cases.
49+
* And pinning the element type to `AuthSessionAuditEventInput` is what makes
50+
* the assertions below type-check against the REAL event shape rather than
51+
* against `any`: a field renamed on the event surface fails here at compile
52+
* time instead of quietly comparing `undefined` to `undefined`.
53+
*/
54+
type RecordAuthEventSpy = { mock: { calls: Array<[AuthSessionAuditEventInput]> } };
55+
56+
/** Every event the sink was handed, in call order. */
57+
function recordedEvents(spy: RecordAuthEventSpy): AuthSessionAuditEventInput[] {
58+
return spy.mock.calls.map(([event]) => event);
59+
}
60+
61+
/**
62+
* The first event the sink was handed.
63+
*
64+
* The "never called" case is named here rather than left to blow up on a
65+
* property access: a writer that was never wired is precisely the failure these
66+
* cases exist to catch, and it should read as that sentence, not as a
67+
* `TypeError` about `undefined`.
68+
*/
69+
function firstEvent(spy: RecordAuthEventSpy): AuthSessionAuditEventInput {
70+
const [event] = recordedEvents(spy);
71+
if (!event) throw new Error('the audit sink was never handed an event');
72+
return event;
73+
}
74+
3575
function hooksWithSink(config: Record<string, unknown> = {}) {
36-
const recordAuthEvent = vi.fn(async () => undefined);
76+
const recordAuthEvent = vi.fn(async (_event: AuthSessionAuditEventInput) => undefined);
3777
const manager = new AuthManager({
3878
secret: SECRET,
3979
baseUrl: 'http://localhost:3000',
@@ -51,7 +91,7 @@ describe('[#8144] session.create.after records a login', () => {
5191
await hooks.session.create.after(SESSION, { path: '/sign-in/email' });
5292

5393
expect(recordAuthEvent).toHaveBeenCalledTimes(1);
54-
expect(recordAuthEvent.mock.calls[0][0]).toEqual({
94+
expect(firstEvent(recordAuthEvent)).toEqual({
5595
action: 'login',
5696
userId: 'usr_1',
5797
sessionId: 'ses_1',
@@ -74,7 +114,7 @@ describe('[#8144] session.create.after records a login', () => {
74114
}
75115

76116
expect(recordAuthEvent).toHaveBeenCalledTimes(4);
77-
expect(recordAuthEvent.mock.calls.map((c: any[]) => c[0].action)).toEqual([
117+
expect(recordedEvents(recordAuthEvent).map((event) => event.action)).toEqual([
78118
'login',
79119
'login',
80120
'login',
@@ -90,7 +130,7 @@ describe('[#8144] session.create.after records a login', () => {
90130
{ path: '/admin/impersonate-user' },
91131
);
92132

93-
const event = recordAuthEvent.mock.calls[0][0];
133+
const event = firstEvent(recordAuthEvent);
94134
expect(event.userId).toBe('usr_1');
95135
expect(event.actor).toBe('usr_admin');
96136
expect(event.context).toEqual({
@@ -160,7 +200,7 @@ describe('[#8144] session.delete.after records a logout — and ONLY for /sign-o
160200
await hooks.session.delete.after(SESSION, { path: SIGN_OUT_PATH });
161201

162202
expect(recordAuthEvent).toHaveBeenCalledTimes(1);
163-
expect(recordAuthEvent.mock.calls[0][0]).toEqual({
203+
expect(firstEvent(recordAuthEvent)).toEqual({
164204
action: 'logout',
165205
userId: 'usr_1',
166206
sessionId: 'ses_1',

0 commit comments

Comments
 (0)