BillTracker/client/api.sessionExpiry.test.ts

55 lines
2.0 KiB
TypeScript

// @vitest-environment jsdom
// Session-expiry contract: the auth middleware is the only source of the
// AUTH_REQUIRED code (no valid session) — api.ts dispatches 'auth:expired' on
// it so AuthProvider clears the user and RequireAuth redirects. Any other 401
// (code AUTH_ERROR — wrong credential input, e.g. change-password with a bad
// current password, on ANY path) must NOT dispatch. A path prefix cannot make
// that distinction — /profile/change-password proved it in review.
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { api } from '@/api';
function mock401(code: string): typeof fetch {
return vi.fn(
async () =>
new Response(JSON.stringify({ error: code, message: 'Unauthorized', code }), {
status: 401,
headers: { 'Content-Type': 'application/json' },
}),
) as unknown as typeof fetch;
}
describe('session-expiry dispatch on 401', () => {
let expiredEvents: number;
const onExpired = () => {
expiredEvents += 1;
};
beforeEach(() => {
expiredEvents = 0;
window.addEventListener('auth:expired', onExpired);
});
afterEach(() => {
window.removeEventListener('auth:expired', onExpired);
vi.unstubAllGlobals();
});
it('dispatches auth:expired for AUTH_REQUIRED (dead session) on a data endpoint', async () => {
vi.stubGlobal('fetch', mock401('AUTH_REQUIRED'));
await expect(api.bills()).rejects.toMatchObject({ status: 401 });
expect(expiredEvents).toBe(1);
});
it('does NOT dispatch for AUTH_ERROR (wrong credential) — even outside /auth/*', async () => {
vi.stubGlobal('fetch', mock401('AUTH_ERROR'));
await expect(
api.changeProfilePassword({ current_password: 'wrong', new_password: 'longenough1' }),
).rejects.toMatchObject({ status: 401 });
await expect(
api.changePassword({ current_password: 'wrong', new_password: 'longenough1' }),
).rejects.toMatchObject({ status: 401 });
expect(expiredEvents).toBe(0);
});
});