|
| 1 | +import { createHmac } from 'node:crypto'; |
| 2 | +import { execFileSync } from 'node:child_process'; |
| 3 | +import { describe, expect, it, vi } from 'vitest'; |
| 4 | +import origins from './fixtures/origin.v1.json'; |
| 5 | +import submission from './fixtures/submission-binding.v1.json'; |
| 6 | +import credential from './fixtures/api-key-credential.v1.json'; |
| 7 | +import { |
| 8 | + canonicalBytes, |
| 9 | + exactOrigin, |
| 10 | + parseBearer, |
| 11 | + parseBinding, |
| 12 | + parseExchange, |
| 13 | + signedOnlyDouble, |
| 14 | + verifyBinding, |
| 15 | +} from './service-double'; |
| 16 | + |
| 17 | +const origin = origins.valid[0]; |
| 18 | +const body = () => ({ schemaVersion: 1, ...submission.bound, origin }); |
| 19 | +function headers() { |
| 20 | + return new Headers({ |
| 21 | + Authorization: credential.authorization, |
| 22 | + 'Content-Type': 'application/json', |
| 23 | + Accept: 'application/vnd.bugdrop.submission-capability.v1+json', |
| 24 | + 'X-BugDrop-Contract-Version': '1', |
| 25 | + 'X-BugDrop-SDK-Version': '0.1.0-preview.7', |
| 26 | + }); |
| 27 | +} |
| 28 | + |
| 29 | +describe('SDK protocol v1 compatibility (test-only service boundary)', () => { |
| 30 | + it('checks the pinned fixture hashes in ordinary CI', () => { |
| 31 | + expect( |
| 32 | + execFileSync(process.execPath, ['scripts/protocol/check-fixture-drift.mjs'], { |
| 33 | + encoding: 'utf8', |
| 34 | + }) |
| 35 | + ).toContain('Protocol fixtures match'); |
| 36 | + }); |
| 37 | + |
| 38 | + it('derives the exact SDK bearer bytes without sending the root credential', () => { |
| 39 | + const auth = createHmac('sha256', canonicalBytes(credential.rootSecret, 32)) |
| 40 | + .update(`bugdrop:auth:v1\0${credential.keyId}`) |
| 41 | + .digest('base64url'); |
| 42 | + expect(auth).toBe(credential.authSecret); |
| 43 | + expect(credential.authorization).toBe(`Bearer bd_auth_v1.${credential.keyId}.${auth}`); |
| 44 | + expect(parseBearer(credential.authorization)).toEqual({ |
| 45 | + keyId: credential.keyId, |
| 46 | + authSecret: Buffer.from(auth, 'base64url'), |
| 47 | + }); |
| 48 | + }); |
| 49 | + |
| 50 | + it.each([ |
| 51 | + ...credential.invalidAuthorizations, |
| 52 | + ...credential.invalidApiKeys.map(value => `Bearer ${value}`), |
| 53 | + null, |
| 54 | + '', |
| 55 | + `Bearer ${credential.apiKey}`, |
| 56 | + credential.authorization.toLowerCase(), |
| 57 | + ` ${credential.authorization}`, |
| 58 | + `${credential.authorization} `, |
| 59 | + `${credential.authorization}\n`, |
| 60 | + `${credential.authorization}\r\n`, |
| 61 | + credential.authorization.replace('Bearer ', 'Bearer '), |
| 62 | + `${credential.authorization}=`, |
| 63 | + `${credential.authorization.slice(0, -1)}p`, |
| 64 | + credential.authorization.replace(credential.keyId, `${credential.keyId}=`), |
| 65 | + credential.authorization.replace(credential.keyId, `${credential.keyId.slice(0, -1)}x`), |
| 66 | + ])('rejects noncanonical or wrong credentials: %s', value => { |
| 67 | + expect(() => parseBearer(value)).toThrow(); |
| 68 | + }); |
| 69 | + |
| 70 | + it.each(origins.valid)('accepts canonical origin %s', value => { |
| 71 | + expect(exactOrigin(value)).toBe(value); |
| 72 | + expect(parseExchange(headers(), { ...body(), origin: value }, value).origin).toBe(value); |
| 73 | + }); |
| 74 | + it.each([ |
| 75 | + ...origins.invalid, |
| 76 | + 'https://app.example.com/', |
| 77 | + 'https://app.example.com?', |
| 78 | + 'https://app.example.com#', |
| 79 | + 'null', |
| 80 | + 'https://app.example.com.:8443', |
| 81 | + 'http://localhost.', |
| 82 | + ])('rejects origin alias %s', value => { |
| 83 | + expect(() => exactOrigin(value)).toThrow(); |
| 84 | + expect(() => parseExchange(headers(), { ...body(), origin: value }, origin)).toThrow(); |
| 85 | + expect(() => parseExchange(headers(), body(), value)).toThrow(); |
| 86 | + }); |
| 87 | + it.each([ |
| 88 | + 'https://other.example.com', |
| 89 | + 'https://app.example.com:8443', |
| 90 | + 'https://sub.app.example.com', |
| 91 | + ])('rejects canonical but unconfigured origin %s', value => { |
| 92 | + expect(() => parseExchange(headers(), { ...body(), origin: value }, origin)).toThrow(); |
| 93 | + }); |
| 94 | + |
| 95 | + it.each(submission.verificationCases)('$name matches the SDK binding result', vector => { |
| 96 | + const verify = () => |
| 97 | + verifyBinding(submission.bound, vector.submissionId, Buffer.from(vector.requestBody)); |
| 98 | + if (vector.accepted) expect(verify).not.toThrow(); |
| 99 | + else expect(verify).toThrow(); |
| 100 | + }); |
| 101 | + it.each([ |
| 102 | + ...submission.invalidPayloadDigests, |
| 103 | + `${submission.bound.payloadDigest.slice(0, -1)}R`, |
| 104 | + '', |
| 105 | + null, |
| 106 | + 42, |
| 107 | + ])('rejects invalid digest %s', payloadDigest => { |
| 108 | + expect(() => parseExchange(headers(), { ...body(), payloadDigest }, origin)).toThrow(); |
| 109 | + }); |
| 110 | + it.each(['', 'a'.repeat(201), 'é'.repeat(101), '\ud800', '\udc00', null, 42])( |
| 111 | + 'rejects invalid submission ID', |
| 112 | + submissionId => { |
| 113 | + expect(() => parseExchange(headers(), { ...body(), submissionId }, origin)).toThrow(); |
| 114 | + } |
| 115 | + ); |
| 116 | + it.each(['a', 'é'.repeat(100), '🪲'.repeat(50), ' opaque id '])( |
| 117 | + 'preserves valid opaque IDs exactly', |
| 118 | + submissionId => { |
| 119 | + expect(parseBinding({ ...submission.bound, submissionId }).submissionId).toBe(submissionId); |
| 120 | + } |
| 121 | + ); |
| 122 | + it.each(['submissionId', 'payloadDigest', 'schemaVersion'])('requires %s', field => { |
| 123 | + const request: Record<string, unknown> = body(); |
| 124 | + delete request[field]; |
| 125 | + expect(() => parseExchange(headers(), request, origin)).toThrow(); |
| 126 | + }); |
| 127 | + it('captures the explicit SDK version separately from schema version', () => { |
| 128 | + expect(parseExchange(headers(), body(), origin)).toEqual({ |
| 129 | + ...submission.bound, |
| 130 | + origin, |
| 131 | + sdkVersion: '0.1.0-preview.7', |
| 132 | + }); |
| 133 | + const requestHeaders = headers(); |
| 134 | + requestHeaders.set('X-BugDrop-SDK-Version', '2.3.4'); |
| 135 | + expect(parseExchange(requestHeaders, body(), origin).sdkVersion).toBe('2.3.4'); |
| 136 | + }); |
| 137 | + it.each([ |
| 138 | + 'Authorization', |
| 139 | + 'Content-Type', |
| 140 | + 'Accept', |
| 141 | + 'X-BugDrop-Contract-Version', |
| 142 | + 'X-BugDrop-SDK-Version', |
| 143 | + ])('fails closed without %s', field => { |
| 144 | + const requestHeaders = headers(); |
| 145 | + requestHeaders.delete(field); |
| 146 | + expect(() => parseExchange(requestHeaders, body(), origin)).toThrow(); |
| 147 | + }); |
| 148 | + it.each([ |
| 149 | + 'subject', |
| 150 | + 'sub', |
| 151 | + 'reporterId', |
| 152 | + 'userId', |
| 153 | + 'pseudonym', |
| 154 | + 'email', |
| 155 | + 'reporter', |
| 156 | + 'metadata', |
| 157 | + 'applicationId', |
| 158 | + 'repository', |
| 159 | + 'installation', |
| 160 | + 'labels', |
| 161 | + ])('rejects unexpected %s without reflecting canary data', field => { |
| 162 | + const canary = 'private-end-user-canary'; |
| 163 | + try { |
| 164 | + parseExchange(headers(), { ...body(), [field]: { identity: canary } }, origin); |
| 165 | + throw new Error('Unexpected acceptance'); |
| 166 | + } catch (error) { |
| 167 | + expect(error).toBeInstanceOf(TypeError); |
| 168 | + expect(String(error)).not.toContain(canary); |
| 169 | + } |
| 170 | + }); |
| 171 | + |
| 172 | + it.each(['auth', 'binding', 'delivery'])( |
| 173 | + 'never invokes anonymous transport after %s failure', |
| 174 | + async failure => { |
| 175 | + const network = vi |
| 176 | + .spyOn(globalThis, 'fetch') |
| 177 | + .mockRejectedValue(new Error('No network allowed')); |
| 178 | + const managed = vi.fn(async () => { |
| 179 | + if (failure === 'delivery') throw new Error('unavailable'); |
| 180 | + }); |
| 181 | + const verify = vi.fn(async () => { |
| 182 | + if (failure === 'auth') throw new Error('unauthorized'); |
| 183 | + return submission.bound; |
| 184 | + }); |
| 185 | + try { |
| 186 | + expect( |
| 187 | + await signedOnlyDouble( |
| 188 | + verify, |
| 189 | + submission.bound.submissionId, |
| 190 | + Buffer.from( |
| 191 | + failure === 'binding' ? `${submission.requestBody} ` : submission.requestBody |
| 192 | + ), |
| 193 | + managed |
| 194 | + ) |
| 195 | + ).toBe('rejected'); |
| 196 | + expect(managed).toHaveBeenCalledTimes(failure === 'delivery' ? 1 : 0); |
| 197 | + expect(network).not.toHaveBeenCalled(); |
| 198 | + } finally { |
| 199 | + network.mockRestore(); |
| 200 | + } |
| 201 | + } |
| 202 | + ); |
| 203 | + it('only hands a verified exact body to managed delivery once', async () => { |
| 204 | + const managed = vi.fn(async () => {}); |
| 205 | + expect( |
| 206 | + await signedOnlyDouble( |
| 207 | + async () => submission.bound, |
| 208 | + submission.bound.submissionId, |
| 209 | + Buffer.from(submission.requestBody), |
| 210 | + managed |
| 211 | + ) |
| 212 | + ).toBe('accepted'); |
| 213 | + expect(managed).toHaveBeenCalledExactlyOnceWith(Buffer.from(submission.requestBody)); |
| 214 | + }); |
| 215 | +}); |
0 commit comments