|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * [#16582] `organizations.invite` declares `role?` optional, so the shorter |
| 5 | + * call it advertises must actually work: omitting `role` sends `'member'`. |
| 6 | + * |
| 7 | + * ## The defect |
| 8 | + * |
| 9 | + * better-auth 1.7.2's body schema for `POST /organization/invite-member` makes |
| 10 | + * `role` REQUIRED. The SDK declared it optional and forwarded the caller's |
| 11 | + * object verbatim, so the documented-looking minimal call — |
| 12 | + * `invite({ email, organizationId })` — was refused with |
| 13 | + * `400 [body.role] Invalid input` (`VALIDATION_ERROR`) before it reached any |
| 14 | + * ObjectStack code. Its sibling `invitations.resend` has always substituted |
| 15 | + * `'member'` over the SAME vendor endpoint, which is exactly why the gap stayed |
| 16 | + * invisible: one member of the family papered over the vendor's requirement and |
| 17 | + * the other did not. |
| 18 | + * |
| 19 | + * ## Why this file boots the real server rather than asserting on a double |
| 20 | + * |
| 21 | + * The claim under test is "the vendor accepts what the SDK now sends". Only |
| 22 | + * better-auth's own zod body schema can settle that — a hand-written stand-in |
| 23 | + * would let this suite certify the SDK against a requirement this file |
| 24 | + * invented, and a status-only mock would have been green before the fix and |
| 25 | + * green after it. So the arrangement is the card's probe with only the socket |
| 26 | + * stood in for: a real `AuthManager` (better-auth 1.7.2, organization plugin, |
| 27 | + * `teams: { enabled: true }` — its defaults) over a real `ObjectQL` on a real |
| 28 | + * `SqliteWasmDriver`, and an `ObjectStackClient` whose `fetch` hands the |
| 29 | + * `Request` straight to `AuthManager.handleRequest`. |
| 30 | + * |
| 31 | + * Cases ① – ③ are RED on the defect: ① and ③ throw |
| 32 | + * `[body.role] Invalid input`, and ② throws for the same reason before it can |
| 33 | + * observe the role it sent. That is what makes them a pin on the DEFECT. |
| 34 | + * |
| 35 | + * ## Case ④ is a different mechanism and neither half can do the other's job |
| 36 | + * |
| 37 | + * The drive proves the vendor accepts the body; it cannot see a body that |
| 38 | + * carries MORE than it should. The SDK serialises the caller's object straight |
| 39 | + * into the request, so a later "helpful" translation layer could add or rename |
| 40 | + * members without changing a type and without changing a status. ④ therefore |
| 41 | + * holds the request bytes to FULL-STRING equality — never `toContain`, which a |
| 42 | + * body carrying extra members would satisfy. |
| 43 | + * |
| 44 | + * It is also the guard that the default is applied by SUBSTITUTION rather than |
| 45 | + * by ordering. The tempting other spelling, `{ role: 'member', ...req }`, agrees |
| 46 | + * with the shipped one on every status cases ① – ③ can observe, and differs in |
| 47 | + * exactly two places ④ can: it re-orders the body, and — because a spread |
| 48 | + * copies an explicitly-`undefined` member over the default while `??` does not |
| 49 | + * — it puts a caller's `role: undefined` back on the wire as no `role` at all, |
| 50 | + * restoring the 400 for the caller who wrote `invite({ email, role: maybe })`. |
| 51 | + * ④c is that case. |
| 52 | + * |
| 53 | + * ## ⑤ pins the shape of the fix, not just its effect |
| 54 | + * |
| 55 | + * The other self-consistent repair — declaring `role` REQUIRED — is a |
| 56 | + * narrowing of a published request type. It was weighed and rejected on this |
| 57 | + * card: it restates the vendor's requirement at a real cost to every existing |
| 58 | + * caller, while the default costs no type change at all. ⑤ is a compile-time |
| 59 | + * assertion that `role` is still optional, so that route cannot be taken later |
| 60 | + * by accident. |
| 61 | + */ |
| 62 | + |
| 63 | +import { describe, it, expect, expectTypeOf, vi, beforeAll, afterAll } from 'vitest'; |
| 64 | +import { ObjectQL } from '@objectstack/objectql'; |
| 65 | +import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm'; |
| 66 | +import { AuthManager } from '@objectstack/plugin-auth'; |
| 67 | +import * as identityObjects from '@objectstack/platform-objects/identity'; |
| 68 | +import { ObjectStackClient } from './index'; |
| 69 | + |
| 70 | +const SECRET = 'test-secret-at-least-32-chars-long!!'; |
| 71 | +const ORIGIN = 'http://localhost:3000'; |
| 72 | +const PASSWORD = 'S3cure!Passw0rd-16582'; |
| 73 | + |
| 74 | +/** |
| 75 | + * The identity objects this arrangement stands up, read out of |
| 76 | + * `@objectstack/platform-objects/identity` BY SHAPE rather than transcribed: |
| 77 | + * plugin-auth's own list is package-private, and a hand-copied one here would |
| 78 | + * be a second declaration of the same set, drifting the day the plugin |
| 79 | + * registers one more. Same derivation as `auth-rotated-session-token.test.ts`. |
| 80 | + */ |
| 81 | +const IDENTITY_OBJECTS = Object.values( |
| 82 | + identityObjects as unknown as Record<string, unknown>, |
| 83 | +).filter( |
| 84 | + (o): o is Record<string, unknown> => |
| 85 | + !!o && |
| 86 | + typeof o === 'object' && |
| 87 | + typeof (o as { name?: unknown }).name === 'string' && |
| 88 | + typeof (o as { fields?: unknown }).fields === 'object', |
| 89 | +); |
| 90 | + |
| 91 | +/** |
| 92 | + * `beforeCreateOrganization` refuses to mint an organization unless a |
| 93 | + * multi-organization posture is standing, and this suite needs one to invite |
| 94 | + * INTO. Set for the whole file and restored after, so a sibling suite in the |
| 95 | + * same worker is never handed a posture it did not ask for. |
| 96 | + */ |
| 97 | +const PRIOR_POSTURE = process.env.OS_TENANCY_POSTURE; |
| 98 | + |
| 99 | +beforeAll(() => { |
| 100 | + process.env.OS_TENANCY_POSTURE = 'isolated'; |
| 101 | +}); |
| 102 | + |
| 103 | +afterAll(() => { |
| 104 | + if (PRIOR_POSTURE === undefined) delete process.env.OS_TENANCY_POSTURE; |
| 105 | + else process.env.OS_TENANCY_POSTURE = PRIOR_POSTURE; |
| 106 | +}); |
| 107 | + |
| 108 | +let seq = 0; |
| 109 | +const nextEmail = (tag: string) => `os16582-${tag}-${++seq}-${Date.now()}@example.com`; |
| 110 | + |
| 111 | +interface Rig { |
| 112 | + client: ObjectStackClient; |
| 113 | + organizationId: string; |
| 114 | +} |
| 115 | + |
| 116 | +/** |
| 117 | + * A signed-in organization owner and the organization they own — every layer |
| 118 | + * below the SDK is the real one. |
| 119 | + */ |
| 120 | +async function arrange(): Promise<Rig> { |
| 121 | + const engine = new ObjectQL(); |
| 122 | + engine.registerDriver(new SqliteWasmDriver({ filename: ':memory:' }) as never, true); |
| 123 | + await engine.init(); |
| 124 | + for (const object of IDENTITY_OBJECTS) { |
| 125 | + engine.registry.registerObject(object as never, '@objectstack/plugin-auth'); |
| 126 | + } |
| 127 | + await engine.syncSchemas(); |
| 128 | + |
| 129 | + const manager = new AuthManager({ |
| 130 | + secret: SECRET, |
| 131 | + baseUrl: ORIGIN, |
| 132 | + dataEngine: engine, |
| 133 | + } as never); |
| 134 | + |
| 135 | + const client = new ObjectStackClient({ |
| 136 | + baseUrl: ORIGIN, |
| 137 | + fetch: (input: RequestInfo | URL, init?: RequestInit) => |
| 138 | + manager.handleRequest(new Request(String(input), init)), |
| 139 | + }); |
| 140 | + |
| 141 | + await client.auth.register({ |
| 142 | + email: nextEmail('owner'), |
| 143 | + password: PASSWORD, |
| 144 | + name: 'Org Owner', |
| 145 | + }); |
| 146 | + |
| 147 | + const organization = await client.organizations.create({ |
| 148 | + name: 'Invite Default Org', |
| 149 | + slug: `os16582-${seq}-${Date.now()}`, |
| 150 | + }); |
| 151 | + const organizationId = (organization as unknown as { id: string }).id; |
| 152 | + expect(organizationId, 'no organization was minted — the premise of this suite is gone').toBeTruthy(); |
| 153 | + await client.organizations.setActive(organizationId); |
| 154 | + |
| 155 | + return { client, organizationId }; |
| 156 | +} |
| 157 | + |
| 158 | +// ───────────────────────────────────────────────────────────────────────── |
| 159 | +// ① the card's call, against the real vendor schema |
| 160 | +// ───────────────────────────────────────────────────────────────────────── |
| 161 | + |
| 162 | +describe('#16582 organizations.invite defaults role to member', () => { |
| 163 | + it('① the two-argument form is accepted and lands a pending member invitation', async () => { |
| 164 | + const { client, organizationId } = await arrange(); |
| 165 | + |
| 166 | + const invitation = await client.organizations.invite({ |
| 167 | + email: nextEmail('invitee'), |
| 168 | + organizationId, |
| 169 | + }); |
| 170 | + |
| 171 | + // Before the fix this line was never reached: the call threw |
| 172 | + // `[body.role] Invalid input` at 400. |
| 173 | + expect(invitation.status).toBe('pending'); |
| 174 | + expect(invitation.role).toBe('member'); |
| 175 | + expect(invitation.organizationId).toBe(organizationId); |
| 176 | + }); |
| 177 | + |
| 178 | + // ─────────────────────────────────────────────────────────────────────── |
| 179 | + // ② a role the caller DID name is never overwritten |
| 180 | + // ─────────────────────────────────────────────────────────────────────── |
| 181 | + |
| 182 | + it('② an explicit role survives — the default substitutes, it does not clobber', async () => { |
| 183 | + const { client, organizationId } = await arrange(); |
| 184 | + |
| 185 | + const invitation = await client.organizations.invite({ |
| 186 | + email: nextEmail('admin-invitee'), |
| 187 | + role: 'admin', |
| 188 | + organizationId, |
| 189 | + }); |
| 190 | + |
| 191 | + expect(invitation.role).toBe('admin'); |
| 192 | + expect(invitation.status).toBe('pending'); |
| 193 | + }); |
| 194 | + |
| 195 | + // ─────────────────────────────────────────────────────────────────────── |
| 196 | + // ③ the asymmetry the card is about is gone |
| 197 | + // ─────────────────────────────────────────────────────────────────────── |
| 198 | + |
| 199 | + it('③ invite and its sibling resend agree on the shorter call', async () => { |
| 200 | + const { client, organizationId } = await arrange(); |
| 201 | + |
| 202 | + const invited = await client.organizations.invite({ |
| 203 | + email: nextEmail('family-invite'), |
| 204 | + organizationId, |
| 205 | + }); |
| 206 | + const resent = await client.organizations.invitations.resend({ |
| 207 | + email: nextEmail('family-resend'), |
| 208 | + organizationId, |
| 209 | + }); |
| 210 | + |
| 211 | + // One family, one behaviour — this is the equality the card asked for. |
| 212 | + expect(invited.role).toBe(resent.role); |
| 213 | + expect(invited.role).toBe('member'); |
| 214 | + }); |
| 215 | +}); |
| 216 | + |
| 217 | +// ───────────────────────────────────────────────────────────────────────── |
| 218 | +// ④ the request BYTES — a mechanism the drive above structurally cannot see |
| 219 | +// ───────────────────────────────────────────────────────────────────────── |
| 220 | + |
| 221 | +describe('#16582 the bytes organizations.invite puts on the wire', () => { |
| 222 | + const INVITE_URL = `${ORIGIN}/api/v1/auth/organization/invite-member`; |
| 223 | + |
| 224 | + /** The 200 the route answers; identical before and after this card. */ |
| 225 | + const PENDING = JSON.stringify({ |
| 226 | + organizationId: 'org_probe', |
| 227 | + email: 'probe@example.com', |
| 228 | + role: 'member', |
| 229 | + teamId: null, |
| 230 | + status: 'pending', |
| 231 | + expiresAt: '2026-09-12T00:16:41.261Z', |
| 232 | + createdAt: '2026-09-10T00:16:41.261Z', |
| 233 | + inviterId: 'usr_probe', |
| 234 | + id: 'inv_probe', |
| 235 | + }); |
| 236 | + |
| 237 | + function capturing() { |
| 238 | + const fetchMock = vi.fn( |
| 239 | + async () => |
| 240 | + new Response(PENDING, { status: 200, headers: { 'content-type': 'application/json' } }), |
| 241 | + ); |
| 242 | + const client = new ObjectStackClient({ baseUrl: ORIGIN, fetch: fetchMock as never }); |
| 243 | + return { client, fetchMock }; |
| 244 | + } |
| 245 | + |
| 246 | + function soleBody(fetchMock: ReturnType<typeof capturing>['fetchMock']): string { |
| 247 | + expect(fetchMock).toHaveBeenCalledTimes(1); |
| 248 | + const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]; |
| 249 | + expect(url).toBe(INVITE_URL); |
| 250 | + return String(init.body); |
| 251 | + } |
| 252 | + |
| 253 | + it('④a omitting role sends exactly the caller object plus role: "member"', async () => { |
| 254 | + const { client, fetchMock } = capturing(); |
| 255 | + |
| 256 | + await client.organizations.invite({ email: 'probe@example.com', organizationId: 'org_probe' }); |
| 257 | + |
| 258 | + // FULL-STRING equality: a body carrying an extra member, a renamed one, or |
| 259 | + // a second `role` fails here even though the vendor would still answer 200. |
| 260 | + expect(soleBody(fetchMock)).toBe( |
| 261 | + JSON.stringify({ email: 'probe@example.com', organizationId: 'org_probe', role: 'member' }), |
| 262 | + ); |
| 263 | + }); |
| 264 | + |
| 265 | + it('④b naming role sends that role, once', async () => { |
| 266 | + const { client, fetchMock } = capturing(); |
| 267 | + |
| 268 | + await client.organizations.invite({ |
| 269 | + email: 'probe@example.com', |
| 270 | + role: 'admin', |
| 271 | + organizationId: 'org_probe', |
| 272 | + }); |
| 273 | + |
| 274 | + expect(soleBody(fetchMock)).toBe( |
| 275 | + JSON.stringify({ email: 'probe@example.com', role: 'admin', organizationId: 'org_probe' }), |
| 276 | + ); |
| 277 | + }); |
| 278 | + |
| 279 | + it('④c an explicitly-undefined role is the same call as omitting it', async () => { |
| 280 | + const { client, fetchMock } = capturing(); |
| 281 | + |
| 282 | + // What `invite({ email, role: maybeRole })` compiles to when the variable |
| 283 | + // is empty — indistinguishable from omission to the caller, and it must be |
| 284 | + // indistinguishable on the wire too. |
| 285 | + await client.organizations.invite({ |
| 286 | + email: 'probe@example.com', |
| 287 | + role: undefined, |
| 288 | + organizationId: 'org_probe', |
| 289 | + }); |
| 290 | + |
| 291 | + expect(soleBody(fetchMock)).toBe( |
| 292 | + JSON.stringify({ email: 'probe@example.com', role: 'member', organizationId: 'org_probe' }), |
| 293 | + ); |
| 294 | + }); |
| 295 | +}); |
| 296 | + |
| 297 | +// ───────────────────────────────────────────────────────────────────────── |
| 298 | +// ⑤ the pin on the SHAPE of the fix — compile-time, never invoked |
| 299 | +// ───────────────────────────────────────────────────────────────────────── |
| 300 | + |
| 301 | +declare const typedClient: ObjectStackClient; |
| 302 | + |
| 303 | +/** The declared request type of the method this card repairs. */ |
| 304 | +type InviteRequest = Parameters<ObjectStackClient['organizations']['invite']>[0]; |
| 305 | + |
| 306 | +/** |
| 307 | + * Compiled by `packages/client/tsconfig.test.json` (which includes `src/**` and |
| 308 | + * is reached by `package.json`'s `typecheck` script through |
| 309 | + * `check:test-typecheck`), never invoked — every statement is an assertion tsc |
| 310 | + * evaluates, and none of them may perform a request. Same arrangement as |
| 311 | + * `oauth-applications-register-request-members.test.ts`'s pins. |
| 312 | + * |
| 313 | + * This is the guard against the route that was weighed and REJECTED on this |
| 314 | + * card: declaring `role` required. Doing that makes the first statement red |
| 315 | + * (the two-argument literal stops satisfying the parameter) and the |
| 316 | + * `Exclude<…, undefined>` inequality red as well, so the narrowing cannot land |
| 317 | + * quietly under a green suite. |
| 318 | + */ |
| 319 | +export async function inviteRoleStaysOptional16582(): Promise<void> { |
| 320 | + // The call the card is about must remain expressible. |
| 321 | + await typedClient.organizations.invite({ email: 'e@example.com', organizationId: 'o' }); |
| 322 | + // …and so must the bare one. |
| 323 | + await typedClient.organizations.invite({ email: 'e@example.com' }); |
| 324 | + |
| 325 | + // `role` is optional: dropping `undefined` from it changes the type, which is |
| 326 | + // only true while `undefined` is in it. |
| 327 | + expectTypeOf<InviteRequest['role']>().not.toEqualTypeOf< |
| 328 | + Exclude<InviteRequest['role'], undefined> |
| 329 | + >(); |
| 330 | +} |
0 commit comments