Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 90 additions & 0 deletions __tests__/bundle/objectRejectionAuthArms.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import type { FakeGithub } from './fakeGithub'
import path from 'node:path'
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'

import { start } from './fakeGithub'
import { comment, helpersFor, repo, token } from './helpers'
import { runBundle } from './runBundle'

vi.setConfig({ testTimeout: 30_000 })

const preload = path.resolve(__dirname, 'objectRejectionPreload.cjs')

// getErrorDetails (src/utils/auth.ts) folds an object rejection's fields: `'status' in error ? error.status : 'unknown'`
// and `'message' in error && typeof error.message === 'string' ? error.message : String(error)`. Octokit wraps every
// fetch rejection in a RequestError, which always carries both, so neither else-arm runs through the bundle unless a
// client call rejects with a bare object. The preload hands the bundle one Response whose `status` getter throws the
// JSON object the test names, the one read @octokit/request makes outside its try/catch. Each fold must still take
// the "unexpected error" path: warn with the folded details, deny the write, and leave no unhandled rejection.
describe('dist/index.js when a collaborator check rejects with a plain object', () => {
let gh: FakeGithub
const { calls } = helpersFor(() => gh)

const collaboratorPath = `${repo}/collaborators/Codertocat`
const target = `GET ${collaboratorPath}`

beforeAll(async () => {
gh = await start()
})
afterEach(() => gh.reset())
afterAll(() => gh.close())

function env(value: Record<string, unknown>) {
return { NODE_OPTIONS: `--require "${preload}"`, OBJECT_REJECT: target, OBJECT_REJECT_VALUE: JSON.stringify(value) }
}

async function retitle(value: Record<string, unknown>) {
gh.route('GET', collaboratorPath, { status: 204 })
gh.route('PATCH', `${repo}/issues/1`, { status: 200, body: {} })

return runBundle({
eventName: 'issue_comment',
payload: comment('/retitle A better title'),
inputs: { ...token, 'prow-commands': '/retitle' },
apiUrl: gh.url,
env: env(value),
})
}

function warnings(stdout: string) {
return stdout.split(/\r?\n/).filter(line => line.startsWith('::warning::'))
}

it('a status-less object with a message (a socket error shape): status folds to "unknown", the message is kept verbatim, and the retitle is denied', async () => {
const result = await retitle({ code: 'ECONNRESET', message: 'socket hang up' })

expect(result.status, result.stdout).toBe(0)
expect(result.errors).toEqual([])
expect(warnings(result.stdout)).toEqual([
'::warning::encountered unexpected error checking collaborator status: status=unknown, message=socket hang up',
])
expect(result.stderr).not.toMatch(/Unhandled|unhandledRejection/)
// the faulted read reached the fake and was recorded like any other; nothing was written
expect(calls()).toEqual([target])
expect(gh.requestsMatching('PATCH', /\/issues\/1$/)).toEqual([])
})

it('an object with a status but no string message: the status is kept, the message falls back to String(error), and the retitle is denied', async () => {
const result = await retitle({ status: 502, code: 'EAI_AGAIN' })

expect(result.status, result.stdout).toBe(0)
expect(result.errors).toEqual([])
expect(warnings(result.stdout)).toEqual([
'::warning::encountered unexpected error checking collaborator status: status=502, message=[object Object]',
])
expect(result.stderr).not.toMatch(/Unhandled|unhandledRejection/)
expect(calls()).toEqual([target])
expect(gh.requestsMatching('PATCH', /\/issues\/1$/)).toEqual([])
})

it('a 404 carried by a bare object is still read as "not a collaborator" and denies the retitle quietly', async () => {
const result = await retitle({ status: 404, message: 'Not Found' })

expect(result.status, result.stdout).toBe(0)
expect(result.errors).toEqual([])
expect(warnings(result.stdout)).toEqual([])
expect(result.stderr).not.toMatch(/Unhandled|unhandledRejection/)
expect(calls()).toEqual([target])
expect(gh.requestsMatching('PATCH', /\/issues\/1$/)).toEqual([])
})
})
42 changes: 42 additions & 0 deletions __tests__/bundle/objectRejectionPreload.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// Preloaded into the bundle child with `NODE_OPTIONS=--require` by the object-rejection e2e test.
// The first request whose `METHOD /path` equals OBJECT_REJECT still reaches the fake GitHub (so it is
// recorded and answered) but the bundle is handed a Response whose `status` getter throws the plain object
// parsed from OBJECT_REJECT_VALUE. `status` is the first thing @octokit/request reads after its fetch
// try/catch, so the object rejects the `octokit.*` call as-is instead of being wrapped in a RequestError:
// the only way an Error-less object (a `status`-less or `message`-less rejection) reaches the bundle's
// `getErrorDetails` folds through the real client. Every other request, and the matching request's second
// occurrence, is untouched. Fails closed: without both variables the preload throws instead of silently
// running the test against the real client.
'use strict'

const target = process.env.OBJECT_REJECT
if (!target)
throw new Error('objectRejectionPreload: OBJECT_REJECT is unset')
const rawValue = process.env.OBJECT_REJECT_VALUE
if (!rawValue)
throw new Error('objectRejectionPreload: OBJECT_REJECT_VALUE is unset')
const rejection = JSON.parse(rawValue)
if (typeof rejection !== 'object' || rejection === null)
throw new Error('objectRejectionPreload: OBJECT_REJECT_VALUE must be a JSON object')

const realFetch = globalThis.fetch
let armed = true

globalThis.fetch = async (input, init) => {
const href = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url
const method = (init?.method ?? (typeof input === 'object' && 'method' in input ? input.method : 'GET')).toUpperCase()
const response = await realFetch(input, init)
if (!armed || `${method} ${new URL(href).pathname}` !== target)
return response
armed = false
await response.body?.cancel()
return new Proxy(response, {
get(resp, prop) {
if (prop === 'status')
// a non-Error object is the point: the bundle's status-less / message-less folds are what this preload exists to reach
throw rejection
const value = Reflect.get(resp, prop, resp)
return typeof value === 'function' ? value.bind(resp) : value
},
})
}
Loading