|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * #10716 — the two SSO domain-verification routes answer an ADR-0112 error code. |
| 5 | + * |
| 6 | + * Both handlers shape their failure as `code: parsed?.code || <our default>`, |
| 7 | + * and the two halves of that expression have DIFFERENT owners: |
| 8 | + * |
| 9 | + * - `parsed?.code` is @better-auth/sso's own code passing through. It is the |
| 10 | + * vendor's diagnosis and must reach the caller unchanged. |
| 11 | + * - the default is OURS, so ADR-0112 applies to it: SCREAMING_SNAKE, drawn |
| 12 | + * from the registered vocabulary rather than invented at the call site. |
| 13 | + * |
| 14 | + * Both directions are pinned below, and that pairing is the point. Pinning only |
| 15 | + * "our code appears" would go green against a handler that overwrites the |
| 16 | + * vendor's code unconditionally — which would swallow exactly the diagnosis the |
| 17 | + * caller needs (`NO_PENDING_VERIFICATION` tells them to request the DNS record |
| 18 | + * first; a blanket "verification failed" does not). Statuses are pinned in both |
| 19 | + * directions too: the inner status passes through untouched. |
| 20 | + * |
| 21 | + * Rejection cases assert `code` AND `status` per ADR-0112 — a bare "it threw" |
| 22 | + * would pass against a handler that refuses everyone. |
| 23 | + */ |
| 24 | + |
| 25 | +import { describe, it, expect } from 'vitest'; |
| 26 | +// The `/api` subpath is where the built package exposes the ledger VALUE — the |
| 27 | +// root entry re-exports the schemas only. Reading it through the published |
| 28 | +// exports map (i.e. `dist`) is deliberate: this asserts against the surface a |
| 29 | +// consumer actually gets, not against a source file this test could reach. |
| 30 | +import { ERROR_CODE_LEDGER } from '@objectstack/spec/api'; |
| 31 | +import { runRequestDomainVerification, runVerifyDomain, type AuthRequestHandler } from './register-sso-provider.js'; |
| 32 | + |
| 33 | +/** The default this package authors for both routes when the vendor gives no code. */ |
| 34 | +const OUR_DEFAULT = 'DOMAIN_VERIFICATION_FAILED'; |
| 35 | +/** A vendor code with its own meaning — the one an overwrite would destroy. */ |
| 36 | +const VENDOR_CODE = 'NO_PENDING_VERIFICATION'; |
| 37 | + |
| 38 | +const REQUEST_URL = 'https://app.example/api/v1/auth/admin/sso/request-domain-verification'; |
| 39 | +const VERIFY_URL = 'https://app.example/api/v1/auth/admin/sso/verify-domain'; |
| 40 | + |
| 41 | +/** A fake inner @better-auth/sso endpoint that answers one canned response. */ |
| 42 | +function fakeHandle(status: number, body: unknown): AuthRequestHandler { |
| 43 | + return async () => |
| 44 | + new Response(body === undefined ? '' : JSON.stringify(body), { |
| 45 | + status, |
| 46 | + headers: { 'content-type': 'application/json' }, |
| 47 | + }); |
| 48 | +} |
| 49 | + |
| 50 | +const post = (url: string) => |
| 51 | + new Request(url, { |
| 52 | + method: 'POST', |
| 53 | + headers: { 'content-type': 'application/json' }, |
| 54 | + body: JSON.stringify({ providerId: 'p1', domain: 'acme.example' }), |
| 55 | + }); |
| 56 | + |
| 57 | +describe('#10716 SSO domain verification — our default is a registered ADR-0112 code', () => { |
| 58 | + it('request-domain-verification: an uncoded vendor failure answers our SCREAMING default, status passed through', async () => { |
| 59 | + const res = await runRequestDomainVerification(fakeHandle(502, { message: 'upstream exploded' }), post(REQUEST_URL)); |
| 60 | + |
| 61 | + expect(res.body.success).toBe(false); |
| 62 | + expect(res.body.error?.code).toBe(OUR_DEFAULT); |
| 63 | + expect(res.status).toBe(502); |
| 64 | + }); |
| 65 | + |
| 66 | + it('verify-domain: an uncoded vendor failure answers our SCREAMING default, status passed through', async () => { |
| 67 | + // The 404-without-a-code shape: SSO domain verification is off for this env. |
| 68 | + // This is the response the dogfood admin-route probe observes. |
| 69 | + const res = await runVerifyDomain(fakeHandle(404, undefined), post(VERIFY_URL)); |
| 70 | + |
| 71 | + expect(res.body.success).toBe(false); |
| 72 | + expect(res.body.error?.code).toBe(OUR_DEFAULT); |
| 73 | + expect(res.status).toBe(404); |
| 74 | + }); |
| 75 | + |
| 76 | + it('both defaults are SCREAMING_SNAKE and registered for this package — reused, not invented', () => { |
| 77 | + expect(OUR_DEFAULT).toMatch(/^[A-Z][A-Z0-9_]*$/); |
| 78 | + // The mechanical half of "we reused a ledger entry": if this code were a new |
| 79 | + // spelling it would need a `packages/spec` registration, and an unregistered |
| 80 | + // code in a fallback slot is invisible to BOTH error-code gates — a silent |
| 81 | + // fourth state, which is precisely what ADR-0112 D3 exists to prevent. |
| 82 | + expect(ERROR_CODE_LEDGER['@objectstack/plugin-auth']).toContain(OUR_DEFAULT); |
| 83 | + }); |
| 84 | +}); |
| 85 | + |
| 86 | +describe('#10716 the vendor pass-through arm is untouched', () => { |
| 87 | + it('request-domain-verification: the vendor code and status reach the caller unchanged', async () => { |
| 88 | + const res = await runRequestDomainVerification( |
| 89 | + fakeHandle(400, { code: VENDOR_CODE, message: 'vendor copy' }), |
| 90 | + post(REQUEST_URL), |
| 91 | + ); |
| 92 | + |
| 93 | + expect(res.body.error?.code).toBe(VENDOR_CODE); |
| 94 | + expect(res.body.error?.code).not.toBe(OUR_DEFAULT); |
| 95 | + expect(res.status).toBe(400); |
| 96 | + expect(res.body.error?.message).toBe('vendor copy'); |
| 97 | + }); |
| 98 | + |
| 99 | + it('verify-domain: the vendor code and status reach the caller unchanged', async () => { |
| 100 | + const res = await runVerifyDomain(fakeHandle(400, { code: VENDOR_CODE, message: 'vendor copy' }), post(VERIFY_URL)); |
| 101 | + |
| 102 | + expect(res.body.error?.code).toBe(VENDOR_CODE); |
| 103 | + expect(res.body.error?.code).not.toBe(OUR_DEFAULT); |
| 104 | + expect(res.status).toBe(400); |
| 105 | + // This route substitutes friendlier COPY for known vendor codes — the code |
| 106 | + // itself still passes through, which is the half that matters on the wire. |
| 107 | + expect(res.body.error?.message).toContain('Request Domain Verification'); |
| 108 | + }); |
| 109 | + |
| 110 | + it('request-domain-verification: the disabled branch keeps its own dedicated code', async () => { |
| 111 | + // Unchanged by #10716 and pinned so the rename cannot have blurred the two: |
| 112 | + // "the feature is off" is a different answer from "verification failed". |
| 113 | + const res = await runRequestDomainVerification(fakeHandle(404, undefined), post(REQUEST_URL)); |
| 114 | + |
| 115 | + expect(res.body.error?.code).toBe('DOMAIN_VERIFICATION_DISABLED'); |
| 116 | + expect(res.status).toBe(400); |
| 117 | + }); |
| 118 | +}); |
0 commit comments