|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | +// |
| 3 | +// #16760 — `auth.me` and `auth.refreshToken` declare `SessionResponse` (the |
| 4 | +// REST `{ success, data }` envelope) for `GET /api/v1/auth/get-session`, a |
| 5 | +// route that answers bare; and `refreshToken` captured no credential at all. |
| 6 | +// |
| 7 | +// ## Why the server here is the real one |
| 8 | +// |
| 9 | +// Every claim in this file is a claim about BYTES BETTER-AUTH WRITES — which |
| 10 | +// keys `/get-session` puts at the top level, where in them the session |
| 11 | +// credential sits, and what the anonymous answer is. A hand-written double |
| 12 | +// would let this suite certify the SDK against a body this file invented, and |
| 13 | +// the card it closes exists precisely because the declared shape and the real |
| 14 | +// one had drifted apart with nobody measuring. So the arrangement is a real |
| 15 | +// `AuthManager` (better-auth 1.7.2, organization plugin on by its own default) |
| 16 | +// over a real `ObjectQL` on a real `SqliteWasmDriver`, with an |
| 17 | +// `ObjectStackClient` whose `fetch` hands the `Request` straight to |
| 18 | +// `AuthManager.handleRequest`: everything above that call is the SDK's real |
| 19 | +// request path, everything below it is better-auth's real pipeline. |
| 20 | +// |
| 21 | +// ## What each block is for |
| 22 | +// |
| 23 | +// - `① me() delivers the envelope it declares` — the card's first consequence. |
| 24 | +// The decisive assertion is a PARSE against the declared schema, not a key |
| 25 | +// spot-check: the defect is "the declared type is not delivered", so the |
| 26 | +// declaration itself has to be the judge. Its second case pins the ONE gap |
| 27 | +// the lift cannot close (`user.image`, declared string-or-absent, served |
| 28 | +// `null`) as an exhaustive issue list, so the residue cannot quietly grow. |
| 29 | +// - `② the raw keys survive` — `.user` is what the field reads today, while |
| 30 | +// the declared `.data.user` was `undefined`. The fix must not buy the |
| 31 | +// declared shape by breaking the workaround callers were pushed onto. |
| 32 | +// - `③ anonymous stays anonymous` — the route serves the literal `null` at |
| 33 | +// 200. Pinned as the KNOWN residue: it is still outside `SessionResponse`, |
| 34 | +// and this case exists so that stays a measured fact rather than a surprise. |
| 35 | +// - `④ refreshToken captures a credential that actually works` — the card's |
| 36 | +// second consequence, and the one that was NOT a consequence of the envelope |
| 37 | +// at all. The firing control is the credential's SPELLING: the client starts |
| 38 | +// on the signed `token.signature` form `bearer()` hands out, and a working |
| 39 | +// capture moves it to the unsigned one the session body carries. |
| 40 | +// - `⑤ the field the old read named does not exist` — the negative control. |
| 41 | +// `data.token` is absent from the normalized body too, so a regression back |
| 42 | +// to `data.data?.token` cannot pass by accident, and the "enveloping it |
| 43 | +// would have fixed refreshToken" reading stays refuted in code. |
| 44 | + |
| 45 | +import { describe, it, expect, afterEach } from 'vitest'; |
| 46 | +import { ObjectQL } from '@objectstack/objectql'; |
| 47 | +import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm'; |
| 48 | +import { AuthManager } from '@objectstack/plugin-auth'; |
| 49 | +import * as identityObjects from '@objectstack/platform-objects/identity'; |
| 50 | +import { BaseResponseSchema, SessionResponseSchema, SessionSchema } from '@objectstack/spec/api'; |
| 51 | +import { ObjectStackClient } from './index'; |
| 52 | + |
| 53 | +const SECRET = 'test-secret-at-least-32-chars-long!!'; |
| 54 | +const ORIGIN = 'http://localhost:3000'; |
| 55 | +const PASSWORD = 'S3cure!Passw0rd-16760'; |
| 56 | + |
| 57 | +/** |
| 58 | + * The identity objects better-auth's ObjectQL adapter reads and writes on the |
| 59 | + * routes under test, plus every sibling the boot path touches. Read out of |
| 60 | + * `@objectstack/platform-objects/identity` BY SHAPE rather than transcribed: |
| 61 | + * plugin-auth's own `authIdentityObjects` is package-private, and a hand-copied |
| 62 | + * list would be a second declaration of the same set, drifting silently the day |
| 63 | + * the plugin registers one more. |
| 64 | + */ |
| 65 | +const IDENTITY_OBJECTS = Object.values( |
| 66 | + identityObjects as unknown as Record<string, unknown>, |
| 67 | +).filter( |
| 68 | + (o): o is Record<string, unknown> => |
| 69 | + !!o && |
| 70 | + typeof o === 'object' && |
| 71 | + typeof (o as { name?: unknown }).name === 'string' && |
| 72 | + typeof (o as { fields?: unknown }).fields === 'object', |
| 73 | +); |
| 74 | + |
| 75 | +const engines: ObjectQL[] = []; |
| 76 | + |
| 77 | +const makeEngine = async (): Promise<ObjectQL> => { |
| 78 | + const engine = new ObjectQL(); |
| 79 | + engines.push(engine); |
| 80 | + engine.registerDriver(new SqliteWasmDriver({ filename: ':memory:' }) as never, true); |
| 81 | + await engine.init(); |
| 82 | + for (const object of IDENTITY_OBJECTS) { |
| 83 | + engine.registry.registerObject(object as never, '@objectstack/plugin-auth'); |
| 84 | + } |
| 85 | + await engine.syncSchemas(); |
| 86 | + return engine; |
| 87 | +}; |
| 88 | + |
| 89 | +const newClient = (manager: AuthManager, token?: string): ObjectStackClient => |
| 90 | + new ObjectStackClient({ |
| 91 | + baseUrl: ORIGIN, |
| 92 | + ...(token ? { token } : {}), |
| 93 | + fetch: (input: RequestInfo | URL, init?: RequestInit) => |
| 94 | + manager.handleRequest(new Request(String(input), init)), |
| 95 | + }); |
| 96 | + |
| 97 | +/** The credential the client is holding right now. */ |
| 98 | +const storedToken = (client: ObjectStackClient): string | undefined => |
| 99 | + (client as unknown as { token?: string }).token; |
| 100 | + |
| 101 | +let emailSeq = 0; |
| 102 | + |
| 103 | +/** |
| 104 | + * A real manager and a signed-in client, plus BOTH spellings of the session |
| 105 | + * credential the sign-up handed back. |
| 106 | + * |
| 107 | + * The sign-up runs through `manager.handleRequest` rather than through |
| 108 | + * `client.auth.register` for one reason: the SDK's `register` keeps only the |
| 109 | + * body, and the two spellings are what case ④ needs. Measured, they differ: |
| 110 | + * |
| 111 | + * ``` |
| 112 | + * response header `set-auth-token` -> "<token>.<signature>" (SIGNED) |
| 113 | + * response body .token -> "<token>" (UNSIGNED) |
| 114 | + * ``` |
| 115 | + * |
| 116 | + * `bearer()` accepts both, and `session.token` inside `/get-session` stores the |
| 117 | + * unsigned one. |
| 118 | + */ |
| 119 | +const signedIn = async () => { |
| 120 | + const engine = await makeEngine(); |
| 121 | + const manager = new AuthManager({ |
| 122 | + secret: SECRET, |
| 123 | + baseUrl: ORIGIN, |
| 124 | + dataEngine: engine, |
| 125 | + } as never); |
| 126 | + const email = `envelope-${++emailSeq}-${Date.now()}@example.com`; |
| 127 | + const res = await manager.handleRequest( |
| 128 | + new Request(`${ORIGIN}/api/v1/auth/sign-up/email`, { |
| 129 | + method: 'POST', |
| 130 | + headers: { 'Content-Type': 'application/json', Origin: ORIGIN }, |
| 131 | + body: JSON.stringify({ email, password: PASSWORD, name: 'Envelope User' }), |
| 132 | + }), |
| 133 | + ); |
| 134 | + const body = (await res.json()) as { token?: string }; |
| 135 | + const signed = res.headers.get('set-auth-token') ?? ''; |
| 136 | + const unsigned = body.token ?? ''; |
| 137 | + expect(signed, 'sign-up emitted no set-auth-token — the premise of ④ is gone').toBeTruthy(); |
| 138 | + expect(unsigned, 'sign-up returned no body token').toBeTruthy(); |
| 139 | + // The client is handed the SIGNED spelling, which is what `bearer()` |
| 140 | + // advertises to a cross-origin caller through `Access-Control-Expose-Headers`. |
| 141 | + return { manager, client: newClient(manager, signed), signed, unsigned, email }; |
| 142 | +}; |
| 143 | + |
| 144 | +/** Anonymous: a real manager, and a client that has never signed in. */ |
| 145 | +const anonymous = async () => { |
| 146 | + const engine = await makeEngine(); |
| 147 | + const manager = new AuthManager({ |
| 148 | + secret: SECRET, |
| 149 | + baseUrl: ORIGIN, |
| 150 | + dataEngine: engine, |
| 151 | + } as never); |
| 152 | + return { manager, client: newClient(manager) }; |
| 153 | +}; |
| 154 | + |
| 155 | +/** WHO a credential resolves to, asked through better-auth's own API. */ |
| 156 | +const principalFor = async (manager: AuthManager, token: string | undefined) => { |
| 157 | + const auth = (await manager.getAuthInstance()) as unknown as { |
| 158 | + api: { getSession(a: { headers: Headers }): Promise<unknown> }; |
| 159 | + }; |
| 160 | + const session = (await auth.api |
| 161 | + .getSession({ headers: new Headers({ authorization: `Bearer ${token}` }) }) |
| 162 | + .catch(() => null)) as { user?: { id?: string } } | null; |
| 163 | + return session?.user?.id ?? null; |
| 164 | +}; |
| 165 | + |
| 166 | +afterEach(async () => { |
| 167 | + while (engines.length) { |
| 168 | + const engine = engines.pop(); |
| 169 | + await (engine as unknown as { close?: () => Promise<void> })?.close?.().catch(() => {}); |
| 170 | + } |
| 171 | +}); |
| 172 | + |
| 173 | +describe('[#16760] /get-session is lifted into the SessionResponse envelope it declares', () => { |
| 174 | + describe('① me() delivers the envelope it declares', () => { |
| 175 | + it('parses as the declared envelope, with the payload under `data`', async () => { |
| 176 | + const { client } = await signedIn(); |
| 177 | + const res = await client.auth.me(); |
| 178 | + |
| 179 | + // The decisive assertion: the DECLARATION judges the body. On the defect |
| 180 | + // the method returned better-auth's bare `{ user, session }`, which |
| 181 | + // carries no `success` at all, so this parse is red before the fix. |
| 182 | + const envelope = BaseResponseSchema.safeParse(res); |
| 183 | + expect( |
| 184 | + envelope.success, |
| 185 | + `me() did not parse as the declared envelope: ${JSON.stringify(envelope.error?.issues)}`, |
| 186 | + ).toBe(true); |
| 187 | + expect(res.success).toBe(true); |
| 188 | + |
| 189 | + // …and the payload really is under the declared keys, not merely a |
| 190 | + // `data` that exists. `.data.user` was `undefined` on the defect while |
| 191 | + // `.user` — which did not type-check — held the real payload. |
| 192 | + expect(res.data).toBeTruthy(); |
| 193 | + expect(typeof res.data.user?.id).toBe('string'); |
| 194 | + expect(res.data.user?.id).toBeTruthy(); |
| 195 | + expect(res.data.user?.email).toContain('@'); |
| 196 | + |
| 197 | + // `data.session` is judged by its own declared schema for the same |
| 198 | + // reason: a `session` key that is present but not a session would pass a |
| 199 | + // truthiness check and fail every caller. |
| 200 | + const session = SessionSchema.safeParse(res.data.session); |
| 201 | + expect( |
| 202 | + session.success, |
| 203 | + `data.session did not parse as SessionSchema: ${JSON.stringify(session.error?.issues)}`, |
| 204 | + ).toBe(true); |
| 205 | + expect(res.data.session?.userId).toBe(res.data.user?.id); |
| 206 | + }); |
| 207 | + |
| 208 | + it('leaves exactly one declared-type gap, and it is not the envelope', async () => { |
| 209 | + const { client } = await signedIn(); |
| 210 | + const res = await client.auth.me(); |
| 211 | + |
| 212 | + // The FULL declared type still does not parse — for a reason that has |
| 213 | + // nothing to do with this card and that the lift cannot reach: |
| 214 | + // `SessionUserSchema.image` is declared `z.string().optional()`, which |
| 215 | + // does not admit `null`, and better-auth serves `"image": null` for a |
| 216 | + // user who never set one. Filed as #17235 — delete this case with it. |
| 217 | + // |
| 218 | + // Pinned as the exhaustive issue list rather than as "it fails": if the |
| 219 | + // envelope ever regresses, the missing `success` and `data` show up here |
| 220 | + // as extra issues and this case reddens. It is the residue's tripwire, |
| 221 | + // not an acceptance of it. |
| 222 | + const issues = SessionResponseSchema.safeParse(res).error?.issues ?? []; |
| 223 | + expect(issues.map((i) => i.path.join('.'))).toEqual(['data.user.image']); |
| 224 | + }); |
| 225 | + }); |
| 226 | + |
| 227 | + describe('② the raw keys survive the lift', () => { |
| 228 | + it('keeps `.user` / `.session` alongside `data`', async () => { |
| 229 | + const { client } = await signedIn(); |
| 230 | + const res = (await client.auth.me()) as unknown as { |
| 231 | + user?: { id?: string }; |
| 232 | + session?: { id?: string }; |
| 233 | + data: { user?: { id?: string }; session?: { id?: string } }; |
| 234 | + }; |
| 235 | + // Callers were pushed onto `.user` by the very misdeclaration this card |
| 236 | + // fixes. Buying the declared shape by breaking them would trade one |
| 237 | + // silent breakage for another. |
| 238 | + expect(res.user?.id).toBe(res.data.user?.id); |
| 239 | + expect(res.session?.id).toBe(res.data.session?.id); |
| 240 | + }); |
| 241 | + }); |
| 242 | + |
| 243 | + describe('③ anonymous stays anonymous — the known residue', () => { |
| 244 | + it('answers the literal null rather than a signed-in-looking envelope', async () => { |
| 245 | + const { client } = await anonymous(); |
| 246 | + const res = await client.auth.me(); |
| 247 | + // ⚠️ Still outside `SessionResponse`, deliberately: there is no value of |
| 248 | + // that type meaning "nobody is signed in", and widening the published |
| 249 | + // return annotation is a contract-review change, not this card's. What |
| 250 | + // the lift must never do is manufacture `{ success: true, data: {} }` |
| 251 | + // here — an empty session that reads as a real one. |
| 252 | + expect(res).toBeNull(); |
| 253 | + }); |
| 254 | + }); |
| 255 | + |
| 256 | + describe('④ refreshToken captures a credential that actually works', () => { |
| 257 | + it('stores session.token, and that token authenticates', async () => { |
| 258 | + const { manager, client, signed, unsigned } = await signedIn(); |
| 259 | + |
| 260 | + // The firing control is the SPELLING, and it is a measured one: the |
| 261 | + // client starts on the SIGNED credential `bearer()` hands out, while the |
| 262 | + // token inside the session body is the UNSIGNED one. So a `refreshToken` |
| 263 | + // that really captures from the body moves the stored string, and one |
| 264 | + // that captures nothing leaves it exactly where it started. |
| 265 | + // |
| 266 | + // ⛔ Not "seed a deliberately wrong token": that unauthenticates the |
| 267 | + // client, `/get-session` then answers `null` for the anonymous reason, |
| 268 | + // and the case would fail against a CORRECT implementation. |
| 269 | + expect(signed, 'the two spellings coincide — this control cannot fire').not.toBe(unsigned); |
| 270 | + const before = storedToken(client); |
| 271 | + expect(before).toBe(signed); |
| 272 | + |
| 273 | + const res = await client.auth.refreshToken('ignored-by-better-auth'); |
| 274 | + |
| 275 | + const stored = storedToken(client); |
| 276 | + expect( |
| 277 | + stored, |
| 278 | + 'refreshToken captured nothing — it is still a silent no-op', |
| 279 | + ).not.toBe(before); |
| 280 | + expect(stored).toBeTruthy(); |
| 281 | + |
| 282 | + // It is the credential the route actually serves, not one invented here. |
| 283 | + expect(stored).toBe(res.data.session?.token); |
| 284 | + // …and the two are the measured pair, not two unrelated strings. |
| 285 | + expect(String(before).startsWith(String(stored))).toBe(true); |
| 286 | + |
| 287 | + // And it is a WORKING credential, not merely a non-empty string: the |
| 288 | + // whole point of the method is that the caller stays signed in. This is |
| 289 | + // what makes swapping the stored spelling safe rather than merely |
| 290 | + // observed — `bearer()` accepts both, and the server strips the |
| 291 | + // signature before it looks the session up. |
| 292 | + expect(await principalFor(manager, stored)).toBe(res.data.user?.id); |
| 293 | + expect(await principalFor(manager, before)).toBe(res.data.user?.id); |
| 294 | + // The control that must NOT resolve, so "resolves to the user" is a real |
| 295 | + // reading and not something this arrangement answers for any input. |
| 296 | + expect(await principalFor(manager, 'not-the-session-token-16760')).toBeNull(); |
| 297 | + }); |
| 298 | + }); |
| 299 | + |
| 300 | + describe('⑤ the field the old read named does not exist', () => { |
| 301 | + it('has no top-level token and no data.token, before or after the lift', async () => { |
| 302 | + const { client } = await signedIn(); |
| 303 | + const res = (await client.auth.me()) as unknown as { |
| 304 | + token?: unknown; |
| 305 | + data: { token?: unknown; session?: { token?: unknown } }; |
| 306 | + }; |
| 307 | + // The card called `refreshToken`'s failure a CONSEQUENCE of the envelope |
| 308 | + // being misdeclared. It is not: enveloping the body puts nothing at |
| 309 | + // `data.token` either, because the route serves no top-level `token` to |
| 310 | + // lift. The only credential in the body is `data.session.token`. |
| 311 | + expect(res.token).toBeUndefined(); |
| 312 | + expect(res.data.token).toBeUndefined(); |
| 313 | + expect(typeof res.data.session?.token).toBe('string'); |
| 314 | + }); |
| 315 | + }); |
| 316 | +}); |
0 commit comments