import { describe, it, expect, vi, afterEach } from 'vitest' import { readSiteVisitorSessionResponse, siteVisitorFromSessionPayload, useSiteVisitorSession, } from './useSiteVisitorSession' /** * THE A-35 DEFECT CLASS — "any 2xx == authenticated". * * `GET /api/v1/site-auth/session` answers an ANONYMOUS visitor with * `200 { "visitor": null }` (server commit a384d3a15, 2026-07-03, A-35) so a signed-out * page load does not log a red console error. Any gate that reads the STATUS instead of * the PAYLOAD therefore reports "authenticated" for every anonymous visitor, and the * member-gated fetches it guards fire and 401 — once per page view. * * That is not hypothetical: kept's hand-rolled `useVisitorStore().checkAuth()` did exactly * this and produced 346 profile-401s in a single day on kineticenergypt.com, a 1.00 ratio * to session-200s on EVERY day observed (C-619, kept commit 85ba35c). * * The shared composable was already payload-derived — but it OWNS its own fetch, so a site * with an existing store (Pinia, caching, in-flight dedup, its own `apiFetch`) could not * adopt it and re-derived `return true`. These tests pin the payload-level seams that such * a store CAN adopt, so the defect class cannot recur on the next site that grows member * features. * * Every case here is stated as: what the caller may conclude from a given response. The * only response that may ever conclude "authenticated" is one carrying a real visitor. */ function installConsoleSpies() { return { error: vi.spyOn(console, 'error').mockImplementation(() => {}), warn: vi.spyOn(console, 'warn').mockImplementation(() => {}), } } function expectSilent(spies: ReturnType) { expect(spies.error).not.toHaveBeenCalled() expect(spies.warn).not.toHaveBeenCalled() } /** A minimal `Response` double: what a hand-rolled store's own `fetch` hands back. */ function jsonResponse(status: number, body: unknown): Response { return { ok: status >= 200 && status < 300, status, headers: new Headers({ 'content-type': 'application/json' }), clone: () => jsonResponse(status, body), text: async () => JSON.stringify(body), json: async () => body, } as unknown as Response } afterEach(() => { vi.unstubAllGlobals() vi.restoreAllMocks() }) describe('siteVisitorFromSessionPayload — the payload is the only source of truth', () => { it('the anonymous 200 body {visitor:null} yields no visitor', () => { expect(siteVisitorFromSessionPayload({ visitor: null })).toBeNull() }) it('a MISSING visitor key yields null, never a truthy undefined', () => { // kept's pre-fix store assigned `data.visitor` raw, so a missing key stored // `undefined` — falsy by luck, and truthy under any `!== null` test written later. expect(siteVisitorFromSessionPayload({})).toBeNull() }) it('a server claiming authenticated:true with NO visitor still yields null', () => { // Fail closed: the envelope's own boolean is not evidence of a visitor. expect(siteVisitorFromSessionPayload({ authenticated: true, visitor: null })).toBeNull() }) it('a visitor object with no email is not a visitor', () => { expect(siteVisitorFromSessionPayload({ visitor: { id: 'g-1' } })).toBeNull() }) it('non-object bodies yield null', () => { expect(siteVisitorFromSessionPayload(null)).toBeNull() expect(siteVisitorFromSessionPayload(undefined)).toBeNull() expect(siteVisitorFromSessionPayload('ok')).toBeNull() expect(siteVisitorFromSessionPayload(true)).toBeNull() }) it('a real visitor is returned, normalized', () => { const v = siteVisitorFromSessionPayload({ visitor: { id: 'g-1', email: 'v@example.com', name: 'Visitor' }, }) expect(v?.email).toBe('v@example.com') expect(v?.name).toBe('Visitor') }) it('the contracts-spec {authenticated,user} shape is accepted too', () => { expect(siteVisitorFromSessionPayload({ authenticated: true, user: { email: 'u@example.com' } })?.email).toBe( 'u@example.com', ) // name defaults to the email rather than inventing one. expect(siteVisitorFromSessionPayload({ user: { email: 'u@example.com' } })?.name).toBe('u@example.com') }) }) describe('readSiteVisitorSessionResponse — status alone can NEVER conclude authenticated', () => { it('THE REGRESSION PIN: 200 {visitor:null} is NOT authenticated', async () => { const spies = installConsoleSpies() const result = await readSiteVisitorSessionResponse(jsonResponse(200, { visitor: null })) // A status-only gate (`return response.ok`) passes every other test in this file // but fails this one. That is the whole point. expect(result.authenticated).toBe(false) expect(result.visitor).toBeNull() expect(result.apiUnreachable).toBeFalsy() expectSilent(spies) }) it('200 with a missing visitor key is NOT authenticated', async () => { const result = await readSiteVisitorSessionResponse(jsonResponse(200, {})) expect(result.authenticated).toBe(false) expect(result.visitor).toBeNull() }) it('200 with a real visitor IS authenticated', async () => { const result = await readSiteVisitorSessionResponse( jsonResponse(200, { visitor: { id: 'g-1', email: 'v@example.com', name: 'Visitor' } }), ) expect(result.authenticated).toBe(true) expect(result.visitor?.email).toBe('v@example.com') }) it('a legacy 401 is not authenticated, and is silent (the anonymous path on old servers)', async () => { const spies = installConsoleSpies() const result = await readSiteVisitorSessionResponse(jsonResponse(401, { error: 'Not authenticated' })) expect(result.authenticated).toBe(false) expect(result.visitor).toBeNull() expectSilent(spies) }) it('a 500 is not authenticated — fail closed, never fail open', async () => { const result = await readSiteVisitorSessionResponse(jsonResponse(500, { error: 'boom' })) expect(result.authenticated).toBe(false) }) it('a non-2xx that somehow carries a visitor is STILL not authenticated', async () => { // Fail closed: a rejected status is a rejection whatever the body says. const result = await readSiteVisitorSessionResponse( jsonResponse(401, { visitor: { email: 'v@example.com', name: 'V' } }), ) expect(result.authenticated).toBe(false) expect(result.visitor).toBeNull() }) it('a missing response (the callers fetch threw) is not authenticated, and silent', async () => { const spies = installConsoleSpies() expect((await readSiteVisitorSessionResponse(null)).authenticated).toBe(false) expect((await readSiteVisitorSessionResponse(undefined)).authenticated).toBe(false) expectSilent(spies) }) it('a body that will not parse is not authenticated, and silent', async () => { const broken = { ok: true, status: 200, headers: new Headers({ 'content-type': 'application/json' }), clone: () => broken, text: async () => '', json: async () => { throw new Error('connection reset') }, } as unknown as Response const spies = installConsoleSpies() const result = await readSiteVisitorSessionResponse(broken) expect(result.authenticated).toBe(false) expect(result.apiUnreachable).toBeFalsy() expectSilent(spies) }) }) describe('readSiteVisitorSessionResponse — a misroute is not an anonymous visit (C-298 layer 2)', () => { const SPA_SHELL = '
' function htmlResponse(): Response { const build = (): Response => ({ ok: true, status: 200, headers: new Headers({ 'content-type': 'text/html; charset=utf-8' }), clone: () => build(), text: async () => SPA_SHELL, json: async () => JSON.parse(SPA_SHELL), }) as unknown as Response return build() } it('200 text/html is signed-out for the UI, apiUnreachable, and LOUD', async () => { const spies = installConsoleSpies() const result = await readSiteVisitorSessionResponse(htmlResponse()) expect(result.authenticated).toBe(false) expect(result.apiUnreachable).toBe(true) expect(spies.error).toHaveBeenCalledTimes(1) const message = String(spies.error.mock.calls[0][0]) expect(message).toContain('bodyClass=spa-html') }) }) describe('useSiteVisitorSession — consumers inherit the payload gate without opting in', () => { it('isAuthenticated stays false across an anonymous 200-null probe', async () => { vi.stubGlobal( 'fetch', vi.fn().mockResolvedValue({ ok: true, status: 200, json: async () => ({ visitor: null }) }), ) const session = useSiteVisitorSession({ fetchOnMount: false }) expect(session.isAuthenticated.value).toBe(false) await session.refresh() expect(session.isAuthenticated.value).toBe(false) expect(session.visitor.value).toBeNull() }) it('isAuthenticated flips true only when a visitor is present', async () => { vi.stubGlobal( 'fetch', vi.fn().mockResolvedValue({ ok: true, status: 200, json: async () => ({ visitor: { id: 'g-1', email: 'v@example.com', name: 'Visitor' } }), }), ) const session = useSiteVisitorSession({ fetchOnMount: false }) await session.refresh() expect(session.isAuthenticated.value).toBe(true) expect(session.visitor.value?.email).toBe('v@example.com') }) })