|
1 | 1 | /** @vitest-environment node */ |
2 | | -import { beforeEach, describe, expect, it, vi } from 'vitest' |
| 2 | +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' |
| 3 | +import type { AsyncValidationResult } from '@/lib/core/security/input-validation.server' |
3 | 4 | import { PayloadSizeLimitError } from '@/lib/core/utils/stream-limits' |
4 | 5 |
|
5 | 6 | const { mockSecureFetch, mockValidateUrl } = vi.hoisted(() => ({ |
@@ -139,6 +140,41 @@ describe('Oracle EPM guarded client', () => { |
139 | 140 | expect(mockSecureFetch.mock.calls[0][0]).toContain('%252e%252e%252fadmin') |
140 | 141 | }) |
141 | 142 |
|
| 143 | + it.each([null, 123, true, {}, ['etag'], new Uint8Array([65])])( |
| 144 | + 'rejects non-string header %j before DNS or fetch', |
| 145 | + async (etag) => { |
| 146 | + const client = createOracleEpmClient({ |
| 147 | + instanceUrl: 'https://epm.example.com', |
| 148 | + accessToken: Buffer.from('u:p').toString('base64'), |
| 149 | + }) |
| 150 | + await expect( |
| 151 | + client.request(getJob, { |
| 152 | + pathParams: { jobId: '42' }, |
| 153 | + headers: { etag: etag as unknown as string }, |
| 154 | + }) |
| 155 | + ).rejects.toMatchObject({ name: 'OracleEpmError', category: 'invalid_input' }) |
| 156 | + expect(mockValidateUrl).not.toHaveBeenCalled() |
| 157 | + expect(mockSecureFetch).not.toHaveBeenCalled() |
| 158 | + } |
| 159 | + ) |
| 160 | + |
| 161 | + it('rejects header objects without invoking their string coercion', async () => { |
| 162 | + const stringifyHeader = vi.fn(() => 'coerced-header') |
| 163 | + const client = createOracleEpmClient({ |
| 164 | + instanceUrl: 'https://epm.example.com', |
| 165 | + accessToken: Buffer.from('u:p').toString('base64'), |
| 166 | + }) |
| 167 | + await expect( |
| 168 | + client.request(getJob, { |
| 169 | + pathParams: { jobId: '42' }, |
| 170 | + headers: { etag: { toString: stringifyHeader } as unknown as string }, |
| 171 | + }) |
| 172 | + ).rejects.toMatchObject({ category: 'invalid_input' }) |
| 173 | + expect(stringifyHeader).not.toHaveBeenCalled() |
| 174 | + expect(mockValidateUrl).not.toHaveBeenCalled() |
| 175 | + expect(mockSecureFetch).not.toHaveBeenCalled() |
| 176 | + }) |
| 177 | + |
142 | 178 | it('rejects malformed UTF-16 path input before URL encoding', async () => { |
143 | 179 | const client = createOracleEpmClient({ |
144 | 180 | instanceUrl: 'https://epm.example.com', |
@@ -305,9 +341,100 @@ describe('Oracle EPM guarded client', () => { |
305 | 341 | signal: controller.signal, |
306 | 342 | }) |
307 | 343 | ).rejects.toMatchObject({ name: 'AbortError' }) |
| 344 | + expect(mockValidateUrl).not.toHaveBeenCalled() |
308 | 345 | expect(mockSecureFetch).not.toHaveBeenCalled() |
309 | 346 | }) |
310 | 347 |
|
| 348 | + describe('DNS cancellation', () => { |
| 349 | + afterEach(() => vi.restoreAllMocks()) |
| 350 | + |
| 351 | + it.each(['deadline', 'caller'] as const)( |
| 352 | + 'ends on %s cancellation even when DNS never settles', |
| 353 | + async (source) => { |
| 354 | + const deadline = new AbortController() |
| 355 | + const caller = new AbortController() |
| 356 | + const timeout = vi.spyOn(AbortSignal, 'timeout').mockReturnValue(deadline.signal) |
| 357 | + mockValidateUrl.mockReturnValueOnce(new Promise<AsyncValidationResult>(() => {})) |
| 358 | + const client = createOracleEpmClient({ |
| 359 | + instanceUrl: 'https://epm.example.com', |
| 360 | + accessToken: Buffer.from('u:p').toString('base64'), |
| 361 | + }) |
| 362 | + const rejected = vi.fn() |
| 363 | + const fulfilled = vi.fn() |
| 364 | + const request = client |
| 365 | + .request(getJob, { pathParams: { jobId: '42' }, signal: caller.signal }) |
| 366 | + .then(fulfilled, rejected) |
| 367 | + const callerReason = new DOMException('caller cancelled', 'AbortError') |
| 368 | + if (source === 'deadline') deadline.abort(new DOMException('deadline', 'TimeoutError')) |
| 369 | + else caller.abort(callerReason) |
| 370 | + |
| 371 | + await vi.waitFor(() => expect(rejected).toHaveBeenCalledTimes(1), { |
| 372 | + interval: 1, |
| 373 | + timeout: 100, |
| 374 | + }) |
| 375 | + await request |
| 376 | + expect(timeout).toHaveBeenCalledWith(5_000) |
| 377 | + expect(rejected).toHaveBeenCalledWith( |
| 378 | + source === 'deadline' |
| 379 | + ? expect.objectContaining({ category: 'timeout', retryable: true }) |
| 380 | + : callerReason |
| 381 | + ) |
| 382 | + expect(fulfilled).not.toHaveBeenCalled() |
| 383 | + expect(mockSecureFetch).not.toHaveBeenCalled() |
| 384 | + } |
| 385 | + ) |
| 386 | + |
| 387 | + it.each(['resolve', 'reject'] as const)( |
| 388 | + 'does not revive a cancelled request when DNS later %ss', |
| 389 | + async (settlement) => { |
| 390 | + const dns = Promise.withResolvers<AsyncValidationResult>() |
| 391 | + mockValidateUrl.mockReturnValueOnce(dns.promise) |
| 392 | + const controller = new AbortController() |
| 393 | + const client = createOracleEpmClient({ |
| 394 | + instanceUrl: 'https://epm.example.com', |
| 395 | + accessToken: Buffer.from('u:p').toString('base64'), |
| 396 | + }) |
| 397 | + const rejected = vi.fn() |
| 398 | + const request = client |
| 399 | + .request(getJob, { pathParams: { jobId: '42' }, signal: controller.signal }) |
| 400 | + .catch(rejected) |
| 401 | + controller.abort(new DOMException('caller cancelled', 'AbortError')) |
| 402 | + await vi.waitFor(() => expect(rejected).toHaveBeenCalledTimes(1), { |
| 403 | + interval: 1, |
| 404 | + timeout: 100, |
| 405 | + }) |
| 406 | + await request |
| 407 | + |
| 408 | + if (settlement === 'resolve') { |
| 409 | + dns.resolve({ |
| 410 | + isValid: true, |
| 411 | + resolvedIP: '203.0.113.10', |
| 412 | + originalHostname: 'epm.example.com', |
| 413 | + }) |
| 414 | + } else { |
| 415 | + dns.reject(new Error('late resolver failure')) |
| 416 | + } |
| 417 | + await Promise.resolve() |
| 418 | + expect(rejected).toHaveBeenCalledTimes(1) |
| 419 | + expect(mockSecureFetch).not.toHaveBeenCalled() |
| 420 | + } |
| 421 | + ) |
| 422 | + |
| 423 | + it('suppresses unexpected DNS rejection details', async () => { |
| 424 | + mockValidateUrl.mockRejectedValueOnce(new Error('private resolver failure')) |
| 425 | + const client = createOracleEpmClient({ |
| 426 | + instanceUrl: 'https://epm.example.com', |
| 427 | + accessToken: Buffer.from('u:p').toString('base64'), |
| 428 | + }) |
| 429 | + const error = await client |
| 430 | + .request(getJob, { pathParams: { jobId: '42' } }) |
| 431 | + .catch((value: unknown) => value) |
| 432 | + expect(error).toMatchObject({ category: 'service_unavailable', retryable: true }) |
| 433 | + expect(String(error)).not.toContain('private resolver failure') |
| 434 | + expect(mockSecureFetch).not.toHaveBeenCalled() |
| 435 | + }) |
| 436 | + }) |
| 437 | + |
311 | 438 | it.each(['download', 'Job Status'])( |
312 | 439 | 'keeps %s links opaque and client-owned', |
313 | 440 | async (relation) => { |
|
0 commit comments