|
| 1 | +import { describe, expect, it } from 'vitest' |
| 2 | +import { |
| 3 | + authFailureCliCode, |
| 4 | + authFailureHint, |
| 5 | + authFailureMessage, |
| 6 | +} from '../failure.js' |
| 7 | + |
| 8 | +const LOGIN_HINT = 'Run `stash auth login` and try again.' |
| 9 | + |
| 10 | +/** |
| 11 | + * An `AuthFailure` as `@cipherstash/auth` returns it. |
| 12 | + * |
| 13 | + * `type` is optional because the module's own `RenderableFailure` widens it to |
| 14 | + * `string | undefined` — so `undefined` and `''` are both shapes the lookups |
| 15 | + * have to survive, not hypotheticals the type system rules out. |
| 16 | + */ |
| 17 | +const failure = (type: string | undefined, message: string, help?: string) => ({ |
| 18 | + ...(type === undefined ? {} : { type }), |
| 19 | + error: new Error(message), |
| 20 | + ...(help ? { help } : {}), |
| 21 | +}) |
| 22 | + |
| 23 | +describe('authFailureMessage', () => { |
| 24 | + it("appends stack-auth's help to the diagnosis", () => { |
| 25 | + // `miette` help is not part of an error's `Display`, so the remedy was |
| 26 | + // dropped at every call site: "Not authenticated" with no mention of how |
| 27 | + // to authenticate. |
| 28 | + expect( |
| 29 | + authFailureMessage( |
| 30 | + failure( |
| 31 | + 'NOT_AUTHENTICATED', |
| 32 | + 'Not authenticated', |
| 33 | + 'Log in with `stash auth login`.', |
| 34 | + ), |
| 35 | + ), |
| 36 | + ).toBe('Not authenticated. Log in with `stash auth login`.') |
| 37 | + }) |
| 38 | + |
| 39 | + it('leaves a failure without help exactly as it was', () => { |
| 40 | + expect(authFailureMessage(failure('INVALID_CLIENT', 'bad client'))).toBe( |
| 41 | + 'bad client', |
| 42 | + ) |
| 43 | + }) |
| 44 | + |
| 45 | + it('does not double a terminal full stop', () => { |
| 46 | + expect( |
| 47 | + authFailureMessage(failure('SERVER_ERROR', 'Boom.', 'Try later.')), |
| 48 | + ).toBe('Boom. Try later.') |
| 49 | + }) |
| 50 | + |
| 51 | + // A CTS diagnosis is a sentence written by whoever raised it, and `.` is not |
| 52 | + // the only way one ends. `'Insufficient balance. Please upgrade your plan.'` |
| 53 | + // is the code path everyone tested; `'Upgrade now!'` is the one that came |
| 54 | + // back as `'Upgrade now!. See the dashboard.'` |
| 55 | + it.each([ |
| 56 | + ['a full stop', 'Boom.', 'Boom. Try later.'], |
| 57 | + ['an exclamation mark', 'Upgrade now!', 'Upgrade now! Try later.'], |
| 58 | + [ |
| 59 | + 'a question mark', |
| 60 | + 'Insufficient balance?', |
| 61 | + 'Insufficient balance? Try later.', |
| 62 | + ], |
| 63 | + ['no punctuation at all', 'Boom', 'Boom. Try later.'], |
| 64 | + ])('joins help to a diagnosis ending in %s', (_what, message, expected) => { |
| 65 | + expect( |
| 66 | + authFailureMessage(failure('SERVER_ERROR', message, 'Try later.')), |
| 67 | + ).toBe(expected) |
| 68 | + }) |
| 69 | + |
| 70 | + it('is the help alone when the diagnosis is empty', () => { |
| 71 | + // Otherwise the separator is all that survives: '. Try later.' |
| 72 | + expect(authFailureMessage(failure('SERVER_ERROR', '', 'Try later.'))).toBe( |
| 73 | + 'Try later.', |
| 74 | + ) |
| 75 | + }) |
| 76 | +}) |
| 77 | + |
| 78 | +describe('authFailureHint', () => { |
| 79 | + it('sends a usage-limit refusal to the dashboard instead of to login', () => { |
| 80 | + // The whole reason this function exists. `LOGIN_HINT` is the right advice |
| 81 | + // for a stale session and wrong for a billing refusal — a fresh login |
| 82 | + // cannot mint a credential CTS is withholding on billing grounds. |
| 83 | + const hint = authFailureHint( |
| 84 | + failure( |
| 85 | + 'USAGE_LIMIT_EXCEEDED', |
| 86 | + 'Insufficient balance.', |
| 87 | + 'Upgrade at https://dashboard.cipherstash.com/billing.', |
| 88 | + ), |
| 89 | + LOGIN_HINT, |
| 90 | + ) |
| 91 | + |
| 92 | + expect(hint).toContain('https://dashboard.cipherstash.com') |
| 93 | + expect(hint).not.toContain('auth login') |
| 94 | + }) |
| 95 | + |
| 96 | + it('sends an unprovisioned org to support, not to billing', () => { |
| 97 | + // A 402 has two causes and they need different remedies: an org over its |
| 98 | + // allowance upgrades, an org the usage system has never heard of has |
| 99 | + // nothing to buy. |
| 100 | + const hint = authFailureHint( |
| 101 | + failure( |
| 102 | + 'ORG_NOT_PROVISIONED', |
| 103 | + 'Not provisioned.', |
| 104 | + 'Contact https://cipherstash.com/support.', |
| 105 | + ), |
| 106 | + LOGIN_HINT, |
| 107 | + ) |
| 108 | + |
| 109 | + expect(hint).toContain('https://cipherstash.com/support') |
| 110 | + expect(hint).not.toContain('dashboard.cipherstash.com') |
| 111 | + }) |
| 112 | + |
| 113 | + it('keeps the caller-supplied hint for an ordinary auth failure', () => { |
| 114 | + expect( |
| 115 | + authFailureHint(failure('EXPIRED_TOKEN', 'Token expired'), LOGIN_HINT), |
| 116 | + ).toBe(LOGIN_HINT) |
| 117 | + }) |
| 118 | + |
| 119 | + it('has no hint of its own when the caller supplies none', () => { |
| 120 | + expect( |
| 121 | + authFailureHint(failure('EXPIRED_TOKEN', 'Token expired')), |
| 122 | + ).toBeUndefined() |
| 123 | + }) |
| 124 | + |
| 125 | + // `type` is `string | undefined` by design (see `RenderableFailure`), so all |
| 126 | + // three of these reach the lookup. An empty type is the one that bit: the |
| 127 | + // old `(failure.type && MAP.get(failure.type)) ?? fallback` short-circuited |
| 128 | + // to `''`, which is not nullish, so `??` never reached the fallback and |
| 129 | + // `stash env` built a `MintError` with `hint: ''` — suppressed by its own |
| 130 | + // `if (failure.hint)` guard, i.e. no hint at all. |
| 131 | + it.each([ |
| 132 | + ['an empty type', ''], |
| 133 | + ['an absent type', undefined], |
| 134 | + ['a type this CLI has never heard of', 'SOME_FUTURE_CODE'], |
| 135 | + ])('falls back to the caller hint for %s', (_what, type) => { |
| 136 | + expect(authFailureHint(failure(type, 'boom'), LOGIN_HINT)).toBe(LOGIN_HINT) |
| 137 | + }) |
| 138 | +}) |
| 139 | + |
| 140 | +describe('authFailureCliCode', () => { |
| 141 | + // The JSON stream's `code` is the only machine-readable field on it. An |
| 142 | + // agent that reads `session_invalid` runs `stash auth login` and comes |
| 143 | + // straight back here — which is the loop for BOTH terminal codes, not just |
| 144 | + // the billing one. |
| 145 | + it.each([ |
| 146 | + ['USAGE_LIMIT_EXCEEDED', 'usage_limit_exceeded'], |
| 147 | + ['ORG_NOT_PROVISIONED', 'org_not_provisioned'], |
| 148 | + ])('reports %s as its own terminal code', (type, expected) => { |
| 149 | + expect( |
| 150 | + authFailureCliCode(failure(type, 'refused'), 'session_invalid'), |
| 151 | + ).toBe(expected) |
| 152 | + }) |
| 153 | + |
| 154 | + it.each([ |
| 155 | + ['an ordinary auth failure', 'EXPIRED_TOKEN'], |
| 156 | + ['an empty type', ''], |
| 157 | + ['an absent type', undefined], |
| 158 | + ['a type this CLI has never heard of', 'SOME_FUTURE_CODE'], |
| 159 | + ])('keeps the caller-supplied code for %s', (_what, type) => { |
| 160 | + expect(authFailureCliCode(failure(type, 'boom'), 'session_invalid')).toBe( |
| 161 | + 'session_invalid', |
| 162 | + ) |
| 163 | + }) |
| 164 | + |
| 165 | + it('has a CLI code for every code that gets a terminal hint', () => { |
| 166 | + // The two tables are what drifted: `ORG_NOT_PROVISIONED` had a hint saying |
| 167 | + // "logging in again will not clear this" while its code still said |
| 168 | + // `session_invalid`. Adding a terminal code has to land in both. |
| 169 | + for (const type of ['USAGE_LIMIT_EXCEEDED', 'ORG_NOT_PROVISIONED']) { |
| 170 | + expect( |
| 171 | + authFailureHint( |
| 172 | + failure(type, 'refused', 'Upstream remedy.'), |
| 173 | + LOGIN_HINT, |
| 174 | + ), |
| 175 | + ).toBe('Upstream remedy.') |
| 176 | + expect( |
| 177 | + authFailureCliCode(failure(type, 'refused'), 'session_invalid'), |
| 178 | + ).not.toBe('session_invalid') |
| 179 | + } |
| 180 | + }) |
| 181 | +}) |
| 182 | + |
| 183 | +describe('the pinned auth taxonomy', () => { |
| 184 | + it('maps a usage refusal to terminal guidance and a stable CLI code', () => { |
| 185 | + const refusal = failure( |
| 186 | + 'USAGE_LIMIT_EXCEEDED', |
| 187 | + 'Insufficient balance.', |
| 188 | + 'Upgrade the plan.', |
| 189 | + ) |
| 190 | + expect(authFailureHint(refusal, LOGIN_HINT)).not.toBe(LOGIN_HINT) |
| 191 | + expect(authFailureCliCode(refusal, 'session_invalid')).toBe( |
| 192 | + 'usage_limit_exceeded', |
| 193 | + ) |
| 194 | + }) |
| 195 | +}) |
0 commit comments