@duffcloudservices/cms 0.13.1 → 0.13.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,243 @@
1
+ import { describe, it, expect, vi, afterEach } from 'vitest'
2
+ import {
3
+ readSiteVisitorSessionResponse,
4
+ siteVisitorFromSessionPayload,
5
+ useSiteVisitorSession,
6
+ } from './useSiteVisitorSession'
7
+
8
+ /**
9
+ * THE A-35 DEFECT CLASS — "any 2xx == authenticated".
10
+ *
11
+ * `GET /api/v1/site-auth/session` answers an ANONYMOUS visitor with
12
+ * `200 { "visitor": null }` (server commit a384d3a15, 2026-07-03, A-35) so a signed-out
13
+ * page load does not log a red console error. Any gate that reads the STATUS instead of
14
+ * the PAYLOAD therefore reports "authenticated" for every anonymous visitor, and the
15
+ * member-gated fetches it guards fire and 401 — once per page view.
16
+ *
17
+ * That is not hypothetical: kept's hand-rolled `useVisitorStore().checkAuth()` did exactly
18
+ * this and produced 346 profile-401s in a single day on kineticenergypt.com, a 1.00 ratio
19
+ * to session-200s on EVERY day observed (C-619, kept commit 85ba35c).
20
+ *
21
+ * The shared composable was already payload-derived — but it OWNS its own fetch, so a site
22
+ * with an existing store (Pinia, caching, in-flight dedup, its own `apiFetch`) could not
23
+ * adopt it and re-derived `return true`. These tests pin the payload-level seams that such
24
+ * a store CAN adopt, so the defect class cannot recur on the next site that grows member
25
+ * features.
26
+ *
27
+ * Every case here is stated as: what the caller may conclude from a given response. The
28
+ * only response that may ever conclude "authenticated" is one carrying a real visitor.
29
+ */
30
+
31
+ function installConsoleSpies() {
32
+ return {
33
+ error: vi.spyOn(console, 'error').mockImplementation(() => {}),
34
+ warn: vi.spyOn(console, 'warn').mockImplementation(() => {}),
35
+ }
36
+ }
37
+
38
+ function expectSilent(spies: ReturnType<typeof installConsoleSpies>) {
39
+ expect(spies.error).not.toHaveBeenCalled()
40
+ expect(spies.warn).not.toHaveBeenCalled()
41
+ }
42
+
43
+ /** A minimal `Response` double: what a hand-rolled store's own `fetch` hands back. */
44
+ function jsonResponse(status: number, body: unknown): Response {
45
+ return {
46
+ ok: status >= 200 && status < 300,
47
+ status,
48
+ headers: new Headers({ 'content-type': 'application/json' }),
49
+ clone: () => jsonResponse(status, body),
50
+ text: async () => JSON.stringify(body),
51
+ json: async () => body,
52
+ } as unknown as Response
53
+ }
54
+
55
+ afterEach(() => {
56
+ vi.unstubAllGlobals()
57
+ vi.restoreAllMocks()
58
+ })
59
+
60
+ describe('siteVisitorFromSessionPayload — the payload is the only source of truth', () => {
61
+ it('the anonymous 200 body {visitor:null} yields no visitor', () => {
62
+ expect(siteVisitorFromSessionPayload({ visitor: null })).toBeNull()
63
+ })
64
+
65
+ it('a MISSING visitor key yields null, never a truthy undefined', () => {
66
+ // kept's pre-fix store assigned `data.visitor` raw, so a missing key stored
67
+ // `undefined` — falsy by luck, and truthy under any `!== null` test written later.
68
+ expect(siteVisitorFromSessionPayload({})).toBeNull()
69
+ })
70
+
71
+ it('a server claiming authenticated:true with NO visitor still yields null', () => {
72
+ // Fail closed: the envelope's own boolean is not evidence of a visitor.
73
+ expect(siteVisitorFromSessionPayload({ authenticated: true, visitor: null })).toBeNull()
74
+ })
75
+
76
+ it('a visitor object with no email is not a visitor', () => {
77
+ expect(siteVisitorFromSessionPayload({ visitor: { id: 'g-1' } })).toBeNull()
78
+ })
79
+
80
+ it('non-object bodies yield null', () => {
81
+ expect(siteVisitorFromSessionPayload(null)).toBeNull()
82
+ expect(siteVisitorFromSessionPayload(undefined)).toBeNull()
83
+ expect(siteVisitorFromSessionPayload('ok')).toBeNull()
84
+ expect(siteVisitorFromSessionPayload(true)).toBeNull()
85
+ })
86
+
87
+ it('a real visitor is returned, normalized', () => {
88
+ const v = siteVisitorFromSessionPayload({
89
+ visitor: { id: 'g-1', email: 'v@example.com', name: 'Visitor' },
90
+ })
91
+ expect(v?.email).toBe('v@example.com')
92
+ expect(v?.name).toBe('Visitor')
93
+ })
94
+
95
+ it('the contracts-spec {authenticated,user} shape is accepted too', () => {
96
+ expect(siteVisitorFromSessionPayload({ authenticated: true, user: { email: 'u@example.com' } })?.email).toBe(
97
+ 'u@example.com',
98
+ )
99
+ // name defaults to the email rather than inventing one.
100
+ expect(siteVisitorFromSessionPayload({ user: { email: 'u@example.com' } })?.name).toBe('u@example.com')
101
+ })
102
+ })
103
+
104
+ describe('readSiteVisitorSessionResponse — status alone can NEVER conclude authenticated', () => {
105
+ it('THE REGRESSION PIN: 200 {visitor:null} is NOT authenticated', async () => {
106
+ const spies = installConsoleSpies()
107
+
108
+ const result = await readSiteVisitorSessionResponse(jsonResponse(200, { visitor: null }))
109
+
110
+ // A status-only gate (`return response.ok`) passes every other test in this file
111
+ // but fails this one. That is the whole point.
112
+ expect(result.authenticated).toBe(false)
113
+ expect(result.visitor).toBeNull()
114
+ expect(result.apiUnreachable).toBeFalsy()
115
+ expectSilent(spies)
116
+ })
117
+
118
+ it('200 with a missing visitor key is NOT authenticated', async () => {
119
+ const result = await readSiteVisitorSessionResponse(jsonResponse(200, {}))
120
+ expect(result.authenticated).toBe(false)
121
+ expect(result.visitor).toBeNull()
122
+ })
123
+
124
+ it('200 with a real visitor IS authenticated', async () => {
125
+ const result = await readSiteVisitorSessionResponse(
126
+ jsonResponse(200, { visitor: { id: 'g-1', email: 'v@example.com', name: 'Visitor' } }),
127
+ )
128
+ expect(result.authenticated).toBe(true)
129
+ expect(result.visitor?.email).toBe('v@example.com')
130
+ })
131
+
132
+ it('a legacy 401 is not authenticated, and is silent (the anonymous path on old servers)', async () => {
133
+ const spies = installConsoleSpies()
134
+ const result = await readSiteVisitorSessionResponse(jsonResponse(401, { error: 'Not authenticated' }))
135
+ expect(result.authenticated).toBe(false)
136
+ expect(result.visitor).toBeNull()
137
+ expectSilent(spies)
138
+ })
139
+
140
+ it('a 500 is not authenticated — fail closed, never fail open', async () => {
141
+ const result = await readSiteVisitorSessionResponse(jsonResponse(500, { error: 'boom' }))
142
+ expect(result.authenticated).toBe(false)
143
+ })
144
+
145
+ it('a non-2xx that somehow carries a visitor is STILL not authenticated', async () => {
146
+ // Fail closed: a rejected status is a rejection whatever the body says.
147
+ const result = await readSiteVisitorSessionResponse(
148
+ jsonResponse(401, { visitor: { email: 'v@example.com', name: 'V' } }),
149
+ )
150
+ expect(result.authenticated).toBe(false)
151
+ expect(result.visitor).toBeNull()
152
+ })
153
+
154
+ it('a missing response (the callers fetch threw) is not authenticated, and silent', async () => {
155
+ const spies = installConsoleSpies()
156
+ expect((await readSiteVisitorSessionResponse(null)).authenticated).toBe(false)
157
+ expect((await readSiteVisitorSessionResponse(undefined)).authenticated).toBe(false)
158
+ expectSilent(spies)
159
+ })
160
+
161
+ it('a body that will not parse is not authenticated, and silent', async () => {
162
+ const broken = {
163
+ ok: true,
164
+ status: 200,
165
+ headers: new Headers({ 'content-type': 'application/json' }),
166
+ clone: () => broken,
167
+ text: async () => '',
168
+ json: async () => {
169
+ throw new Error('connection reset')
170
+ },
171
+ } as unknown as Response
172
+ const spies = installConsoleSpies()
173
+
174
+ const result = await readSiteVisitorSessionResponse(broken)
175
+
176
+ expect(result.authenticated).toBe(false)
177
+ expect(result.apiUnreachable).toBeFalsy()
178
+ expectSilent(spies)
179
+ })
180
+ })
181
+
182
+ describe('readSiteVisitorSessionResponse — a misroute is not an anonymous visit (C-298 layer 2)', () => {
183
+ const SPA_SHELL =
184
+ '<!DOCTYPE html><html><head><script type="module" src="/assets/index.js"></script></head><body><div id="app"></div></body></html>'
185
+
186
+ function htmlResponse(): Response {
187
+ const build = (): Response =>
188
+ ({
189
+ ok: true,
190
+ status: 200,
191
+ headers: new Headers({ 'content-type': 'text/html; charset=utf-8' }),
192
+ clone: () => build(),
193
+ text: async () => SPA_SHELL,
194
+ json: async () => JSON.parse(SPA_SHELL),
195
+ }) as unknown as Response
196
+ return build()
197
+ }
198
+
199
+ it('200 text/html is signed-out for the UI, apiUnreachable, and LOUD', async () => {
200
+ const spies = installConsoleSpies()
201
+
202
+ const result = await readSiteVisitorSessionResponse(htmlResponse())
203
+
204
+ expect(result.authenticated).toBe(false)
205
+ expect(result.apiUnreachable).toBe(true)
206
+ expect(spies.error).toHaveBeenCalledTimes(1)
207
+ const message = String(spies.error.mock.calls[0][0])
208
+ expect(message).toContain('bodyClass=spa-html')
209
+ })
210
+ })
211
+
212
+ describe('useSiteVisitorSession — consumers inherit the payload gate without opting in', () => {
213
+ it('isAuthenticated stays false across an anonymous 200-null probe', async () => {
214
+ vi.stubGlobal(
215
+ 'fetch',
216
+ vi.fn().mockResolvedValue({ ok: true, status: 200, json: async () => ({ visitor: null }) }),
217
+ )
218
+
219
+ const session = useSiteVisitorSession({ fetchOnMount: false })
220
+ expect(session.isAuthenticated.value).toBe(false)
221
+ await session.refresh()
222
+
223
+ expect(session.isAuthenticated.value).toBe(false)
224
+ expect(session.visitor.value).toBeNull()
225
+ })
226
+
227
+ it('isAuthenticated flips true only when a visitor is present', async () => {
228
+ vi.stubGlobal(
229
+ 'fetch',
230
+ vi.fn().mockResolvedValue({
231
+ ok: true,
232
+ status: 200,
233
+ json: async () => ({ visitor: { id: 'g-1', email: 'v@example.com', name: 'Visitor' } }),
234
+ }),
235
+ )
236
+
237
+ const session = useSiteVisitorSession({ fetchOnMount: false })
238
+ await session.refresh()
239
+
240
+ expect(session.isAuthenticated.value).toBe(true)
241
+ expect(session.visitor.value?.email).toBe('v@example.com')
242
+ })
243
+ })
@@ -28,7 +28,12 @@
28
28
  * The anonymous path — `200 {"visitor":null}`, a legacy `401`, a network error — stays
29
29
  * exactly as silent as before.
30
30
  */
31
- import { platformFetch, isPlatformFetchError } from '@duffcloudservices/cms-core'
31
+ import {
32
+ platformFetch,
33
+ readPlatformJson,
34
+ isPlatformFetchError,
35
+ type PlatformBodyClass,
36
+ } from '@duffcloudservices/cms-core'
32
37
  import { ref, computed, onMounted, type Ref, type ComputedRef } from 'vue'
33
38
 
34
39
  /** A signed-in site visitor. */
@@ -67,11 +72,23 @@ const SIGNED_OUT: SiteVisitorSessionResult = { visitor: null, authenticated: fal
67
72
  const apiBase = (value?: string): string => (value ?? '/api/v1').replace(/\/$/u, '')
68
73
 
69
74
  /**
70
- * Normalize the various session-response shapes into a `SiteVisitor | null`.
71
- * Accepts the primary/legacy `{ visitor: {...} | null }` envelope and the
72
- * contracts-spec `{ authenticated, user: {...} }` shape defensively.
75
+ * Normalize a parsed `/site-auth/session` BODY into a `SiteVisitor | null`.
76
+ *
77
+ * This is the payload-level answer to "is anyone signed in?" — the question the HTTP
78
+ * status cannot answer, because an anonymous visitor gets `200 { "visitor": null }`
79
+ * (A-35). Use it when your own code already parsed the body; use
80
+ * {@link readSiteVisitorSessionResponse} when you are holding a `Response`.
81
+ *
82
+ * `null` means signed out. Deliberately fail-closed on every ambiguity:
83
+ * - a MISSING `visitor` key yields `null`, never a truthy `undefined` — the trap that
84
+ * made kept's `visitor.value = data.visitor` a latent defect (C-619);
85
+ * - an envelope claiming `authenticated: true` with no visitor object yields `null`;
86
+ * - a visitor object without an email is not a visitor.
87
+ *
88
+ * Accepts the primary/legacy `{ visitor: {...} | null }` envelope and the contracts-spec
89
+ * `{ authenticated, user: {...} }` shape defensively.
73
90
  */
74
- function extractVisitor(data: unknown): SiteVisitor | null {
91
+ export function siteVisitorFromSessionPayload(data: unknown): SiteVisitor | null {
75
92
  if (!data || typeof data !== 'object') {
76
93
  return null
77
94
  }
@@ -94,6 +111,81 @@ function extractVisitor(data: unknown): SiteVisitor | null {
94
111
  }
95
112
  }
96
113
 
114
+ /**
115
+ * Body classes that mean the request never reached the platform API, as opposed to an
116
+ * API that answered. `empty` and `json` are NOT here: an empty 200 or a truncated body
117
+ * is an ordinary failed read, and reporting it as a misroute would cry wolf.
118
+ */
119
+ const UNREACHABLE_BODY_CLASSES: ReadonlySet<PlatformBodyClass> = new Set<PlatformBodyClass>([
120
+ 'spa-html',
121
+ 'html',
122
+ 'other',
123
+ ])
124
+
125
+ /**
126
+ * Decide "is a visitor signed in?" from a `/site-auth/session` `Response` you already
127
+ * fetched yourself.
128
+ *
129
+ * THIS IS THE SEAM FOR HAND-ROLLED SESSION STORES. {@link useSiteVisitorSession} owns its
130
+ * own fetch, which a site with an existing store (Pinia, caching, in-flight dedup, its own
131
+ * authenticated `apiFetch`) cannot adopt — so it hand-rolls the gate and re-derives the
132
+ * A-35 defect: `if (response.ok) return true`, which reports EVERY anonymous visitor as
133
+ * authenticated because anonymous is `200 { "visitor": null }`. Measured cost when that
134
+ * happened on kept: one member-gated 401 per page view, 346 in a day (C-619).
135
+ *
136
+ * Keep your fetch; hand the `Response` here and read the payload-derived answer:
137
+ *
138
+ * ```ts
139
+ * const response = await apiFetch('/api/v1/site-auth/session')
140
+ * const { visitor: v } = await readSiteVisitorSessionResponse(response)
141
+ * visitor.value = v
142
+ * return v !== null // never `return response.ok`
143
+ * ```
144
+ *
145
+ * Fails CLOSED everywhere: a non-2xx (incl. a legacy `401`), a missing response (your
146
+ * fetch threw), an unparseable body, or a `null` visitor all resolve to signed-out. The
147
+ * status can never on its own produce `authenticated: true` — only a real visitor object
148
+ * in the body can.
149
+ *
150
+ * Silent on every anonymous path. LOUD exactly once when the body was not JSON at all
151
+ * (`apiUnreachable: true`), because "this host does not route `/api/v1/*` to the API" must
152
+ * not masquerade as "signed out" — that ambiguity is how kept ran with nine dead login
153
+ * routes for ~2 weeks (C-261 / C-298 layer 2).
154
+ *
155
+ * Never throws.
156
+ */
157
+ export async function readSiteVisitorSessionResponse(
158
+ response: Response | null | undefined,
159
+ ): Promise<SiteVisitorSessionResult> {
160
+ // The caller's fetch rejected (network / abort / CORS) and passed us nothing.
161
+ if (!response) {
162
+ return SIGNED_OUT
163
+ }
164
+
165
+ // Old servers reply 401 to an anonymous probe; new servers reply 200-null. Treat any
166
+ // non-OK status as signed-out WITHOUT logging — and without reading the body, so a
167
+ // visitor object smuggled alongside a rejected status can never authenticate.
168
+ if (!response.ok) {
169
+ return SIGNED_OUT
170
+ }
171
+
172
+ let data: unknown
173
+ try {
174
+ // `silent: true`: we decide what deserves the console below, so an ordinary failed
175
+ // read stays as quiet as it has always been.
176
+ data = await readPlatformJson<unknown>(response, { silent: true })
177
+ } catch (e) {
178
+ if (isPlatformFetchError(e) && UNREACHABLE_BODY_CLASSES.has(e.bodyClass)) {
179
+ console.error(e.message)
180
+ return { visitor: null, authenticated: false, apiUnreachable: true }
181
+ }
182
+ return SIGNED_OUT
183
+ }
184
+
185
+ const visitor = siteVisitorFromSessionPayload(data)
186
+ return { visitor, authenticated: visitor !== null }
187
+ }
188
+
97
189
  /**
98
190
  * Fetch the current site-visitor session. Never throws and never logs for the
99
191
  * anonymous case: any network error, non-OK status (incl. a legacy `401`), or
@@ -123,21 +215,9 @@ export async function fetchSiteVisitorSession(
123
215
  return SIGNED_OUT
124
216
  }
125
217
 
126
- // Old servers reply 401 to an anonymous probe; new servers reply 200-null.
127
- // Treat any non-OK status as signed-out WITHOUT logging.
128
- if (!response.ok) {
129
- return SIGNED_OUT
130
- }
131
-
132
- let data: unknown
133
- try {
134
- data = await response.json()
135
- } catch {
136
- return SIGNED_OUT
137
- }
138
-
139
- const visitor = extractVisitor(data)
140
- return { visitor, authenticated: visitor !== null }
218
+ // ONE implementation of the gate: the composable, this function, and any site that
219
+ // adopts the seam directly all decide "authenticated" the same way, so they cannot drift.
220
+ return readSiteVisitorSessionResponse(response)
141
221
  }
142
222
 
143
223
  export interface UseSiteVisitorSessionOptions extends FetchSiteVisitorSessionOptions {