|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * [#15451] `oauth.applications.delete` must RESOLVE on the zero-byte 200 its |
| 5 | + * route actually answers — and must still reject, loudly, on anything else. |
| 6 | + * |
| 7 | + * ## Why this file exists at all, when its sibling is type-level |
| 8 | + * |
| 9 | + * `return-type-precision.test.ts` says in its own header that a runtime test |
| 10 | + * cannot observe a return-type narrowing: the value is identical either way. |
| 11 | + * The reverse is true here and is the whole point. This card did not narrow a |
| 12 | + * declaration — it changed what the method DOES. Before it, the method called |
| 13 | + * `res.json()` on a body of zero bytes and REJECTED with `SyntaxError: |
| 14 | + * Unexpected end of JSON input` on every successful delete; after it, the |
| 15 | + * same call resolves. No compile-time assertion can see a reject/resolve |
| 16 | + * flip, so the two files pin the two halves and neither is redundant. |
| 17 | + * |
| 18 | + * ## The wire fact these fixtures encode, measured not assumed |
| 19 | + * |
| 20 | + * Real `betterAuth` + real `@better-auth/oauth-provider` over the real |
| 21 | + * ObjectQL adapter, driven through the real `ObjectStackClient` with only the |
| 22 | + * socket stood in for: |
| 23 | + * |
| 24 | + * POST /api/v1/auth/oauth2/delete-client |
| 25 | + * -> 200 · 0 bytes · content-type: application/json · NO content-length |
| 26 | + * |
| 27 | + * Both shortcuts a reader will reach for were measured and both are unusable, |
| 28 | + * which is why the fix reads the body instead: |
| 29 | + * |
| 30 | + * - `res.status === 204` — the spelling five other delete surfaces in |
| 31 | + * `index.ts` use. The status here is **200**, so it never fires. |
| 32 | + * - `content-length === '0'` — the header is **absent**, not zero, so a |
| 33 | + * header test never fires either and would leave the defect in place |
| 34 | + * while looking like a fix. |
| 35 | + * |
| 36 | + * ## ⛔ The malformed-body case is load-bearing, not leftover |
| 37 | + * |
| 38 | + * The implementation still runs `JSON.parse` on a NON-EMPTY body and throws |
| 39 | + * the result away. That reads like dead code and is not: it is what keeps a |
| 40 | + * malformed response loud, so the ONLY behaviour the card changed is the |
| 41 | + * zero-byte case — the defect itself. Delete the parse "because nothing reads |
| 42 | + * it" and `expect(...).rejects` below goes red, by design. |
| 43 | + */ |
| 44 | + |
| 45 | +import { describe, it, expect, vi } from 'vitest'; |
| 46 | +import { ObjectStackClient } from './index'; |
| 47 | + |
| 48 | +const BASE = 'http://localhost:3000'; |
| 49 | +const DELETE_URL = `${BASE}/api/v1/auth/oauth2/delete-client`; |
| 50 | + |
| 51 | +/** |
| 52 | + * A client whose transport answers with a REAL `Response`. Deliberately not a |
| 53 | + * hand-rolled double with a stubbed `json()`: the defect lived in how a real |
| 54 | + * `Response` behaves when its body is empty, and a double that answers |
| 55 | + * `json: async () => undefined` cannot reproduce it — it would have been |
| 56 | + * green against the broken client too. |
| 57 | + */ |
| 58 | +function clientAnswering(body: BodyInit | null, init?: ResponseInit) { |
| 59 | + const fetchMock = vi.fn(async () => new Response(body, init)); |
| 60 | + const client = new ObjectStackClient({ baseUrl: BASE, fetch: fetchMock as never }); |
| 61 | + return { client, fetchMock }; |
| 62 | +} |
| 63 | + |
| 64 | +/** The exact answer the route was measured to send on a successful delete. */ |
| 65 | +const ZERO_BYTE_200: [BodyInit | null, ResponseInit] = [ |
| 66 | + null, |
| 67 | + { status: 200, headers: { 'content-type': 'application/json' } }, |
| 68 | +]; |
| 69 | + |
| 70 | +describe('#15451 oauth.applications.delete — the zero-byte 200', () => { |
| 71 | + it('RESOLVES on the 200 / zero-byte answer the route actually sends', async () => { |
| 72 | + const { client } = clientAnswering(...ZERO_BYTE_200); |
| 73 | + // ⚠️ RED BEFORE: this rejected with `SyntaxError: Unexpected end of JSON |
| 74 | + // input`, on the successful path, every single time. |
| 75 | + await expect(client.oauth.applications.delete('c_1')).resolves.toBeUndefined(); |
| 76 | + }); |
| 77 | + |
| 78 | + it('resolves on an empty-STRING body too — the same zero bytes, spelled differently', async () => { |
| 79 | + const { client } = clientAnswering('', { status: 200 }); |
| 80 | + await expect(client.oauth.applications.delete('c_1')).resolves.toBeUndefined(); |
| 81 | + }); |
| 82 | + |
| 83 | + it('sends the same request bytes as before — only the RESPONSE handling moved', async () => { |
| 84 | + const { client, fetchMock } = clientAnswering(...ZERO_BYTE_200); |
| 85 | + await client.oauth.applications.delete('c_1'); |
| 86 | + expect(fetchMock).toHaveBeenCalledTimes(1); |
| 87 | + const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]; |
| 88 | + expect(url).toBe(DELETE_URL); |
| 89 | + expect(init.method).toBe('POST'); |
| 90 | + expect(init.body).toBe(JSON.stringify({ client_id: 'c_1' })); |
| 91 | + }); |
| 92 | + |
| 93 | + it('⛔ still REJECTS on a malformed non-empty body — the parse is not decoration', async () => { |
| 94 | + const { client } = clientAnswering('{ not json', { status: 200 }); |
| 95 | + // Green in BOTH states, and recorded as such: it is here to go RED if |
| 96 | + // someone removes the `JSON.parse` as unused, which would trade this |
| 97 | + // card's loud bug for a quiet one. |
| 98 | + await expect(client.oauth.applications.delete('c_1')).rejects.toThrow(SyntaxError); |
| 99 | + }); |
| 100 | + |
| 101 | + it('rejects on a whitespace-only body — the boundary is EXACTLY zero bytes', async () => { |
| 102 | + const { client } = clientAnswering('\n', { status: 200 }); |
| 103 | + // Stated rather than left to drift: the tolerated case is the empty body |
| 104 | + // the route sends, not "anything that looks blank". A body that is |
| 105 | + // present but not JSON is a malformed response and says so. |
| 106 | + await expect(client.oauth.applications.delete('c_1')).rejects.toThrow(SyntaxError); |
| 107 | + }); |
| 108 | + |
| 109 | + it('resolves and DISCARDS a well-formed body, should the route ever grow one', async () => { |
| 110 | + const { client } = clientAnswering(JSON.stringify({ deleted: true }), { |
| 111 | + status: 200, |
| 112 | + headers: { 'content-type': 'application/json' }, |
| 113 | + }); |
| 114 | + // The declared contract is `void`. A payload arriving here is validated |
| 115 | + // and dropped; surfacing it is a deliberate widening of the return type, |
| 116 | + // never a silent change of shape under an unchanged declaration. |
| 117 | + await expect(client.oauth.applications.delete('c_1')).resolves.toBeUndefined(); |
| 118 | + }); |
| 119 | + |
| 120 | + it('"already gone" still arrives as a THROW, which is what makes `void` honest', async () => { |
| 121 | + // The route distinguishes deleted from already-gone on the ERROR channel: |
| 122 | + // a missing client answers 404 `{ error: 'not_found' }`. `this.fetch` |
| 123 | + // raises that before any success value exists, so the success answer has |
| 124 | + // no information left to carry and `{ deleted: true }` would be invented. |
| 125 | + const { client } = clientAnswering( |
| 126 | + JSON.stringify({ error_description: 'client not found', error: 'not_found' }), |
| 127 | + { status: 404, headers: { 'content-type': 'application/json' } }, |
| 128 | + ); |
| 129 | + await expect(client.oauth.applications.delete('gone')).rejects.toThrow(/not_found/); |
| 130 | + }); |
| 131 | +}); |
0 commit comments