diff --git a/layers/artifact-delivery/server/api/v1/resolutions/index.post.ts b/layers/artifact-delivery/server/api/v1/resolutions/index.post.ts index 631a6983..fdbc3bff 100644 --- a/layers/artifact-delivery/server/api/v1/resolutions/index.post.ts +++ b/layers/artifact-delivery/server/api/v1/resolutions/index.post.ts @@ -49,6 +49,7 @@ export default withArtifactProblems(defineApiHandler({ repositoryId: privateAccess.repositoryId, } : { visibility: 'public' }, + requesterAccountId: user?.id ?? null, }) if (result._tag === 'idempotency-conflict') { throw createError({ diff --git a/layers/artifact-delivery/server/utils/build.ts b/layers/artifact-delivery/server/utils/build.ts index 7cfb71d5..2d3cce45 100644 --- a/layers/artifact-delivery/server/utils/build.ts +++ b/layers/artifact-delivery/server/utils/build.ts @@ -29,6 +29,7 @@ import { import { checkArtifactSource, checksBlockArtifact, checksPermitSigning } from './checks' import { canonicalJson, digestHex } from './encoding' import { isRetryableProblem, storedFilesPassLoadRules } from './github-source' +import { withRequesterFallback } from './requester-github' import { ARTIFACT_POLICY_VERSION, BYTE_COMPATIBLE_POLICY_VERSIONS, @@ -46,6 +47,12 @@ export interface ArtifactBuildDependencies { db: D1Database github: PublicGithubSourceClient privateGithub?: (row: ResolutionRow) => Promise + /** + * The GitHub client of the signed-in account that asked for a public + * Resolution, or null. A build uses it only for a read the shared + * credential's quota refused, and only for that account's own Resolution. + */ + requesterGithub?: (row: ResolutionRow) => Promise bucket: R2Bucket privateArtifacts?: { put: (input: { @@ -824,8 +831,12 @@ async function githubForResolution( dependencies: ArtifactBuildDependencies, row: ResolutionRow, ): Promise { - if (row.visibility === 'public') - return dependencies.github + if (row.visibility === 'public') { + const requesterGithub = dependencies.requesterGithub + return requesterGithub + ? withRequesterFallback(dependencies.github, () => requesterGithub(row)) + : dependencies.github + } if (!dependencies.privateGithub) { return { _tag: 'rejected', diff --git a/layers/artifact-delivery/server/utils/queue.ts b/layers/artifact-delivery/server/utils/queue.ts index aad0a5a4..8a0db5fe 100644 --- a/layers/artifact-delivery/server/utils/queue.ts +++ b/layers/artifact-delivery/server/utils/queue.ts @@ -26,6 +26,7 @@ import { createGithubSourceClient, createPublicGithubSourceClient } from './gith import { createD1PrivateArtifactKeyProvider, privateArtifactWrappingKeysFromEnv } from './private-crypto' import { privateArtifactAccessEnabled } from './private-feature' import { putPrivateArtifact } from './private-storage' +import { createRequesterGithubSource, forgetResolutionRequester } from './requester-github' import { getResolution } from './state' import { parseTrustedRoot } from './trusted-root' @@ -93,6 +94,10 @@ export async function consumeArtifactBuildBatch( continue } if (outcome._tag === 'ok') { + // A settled build never reads GitHub again, so it no longer needs to + // know who asked. A superseded one leaves that to the build that won. + if (outcome.value._tag !== 'superseded') + await forgetResolutionRequester(dependencies.db, parsed.data.resolutionId) message.ack() continue } @@ -112,6 +117,7 @@ export async function consumeArtifactBuildBatch( const row = await getResolution(dependencies.db, parsed.data.resolutionId) if (row) await failResolution(dependencies, row, 'SERVICE_UNAVAILABLE', true) + await forgetResolutionRequester(dependencies.db, parsed.data.resolutionId) message.ack() } } @@ -209,9 +215,23 @@ export function createArtifactBuildDependencies(env: Cloudflare.Env): ArtifactBu const privateDependencies = privateArtifactAccessEnabled(env) ? createPrivateBuildDependencies(env, runtimeFetch) : {} + const githubObjects = createKvGithubObjectCache(env.KV_CACHE) return { db: env.DB, - github: createArtifactGithubSource(env, defaultGithubSourceRuntime(), createKvGithubObjectCache(env.KV_CACHE)), + github: createArtifactGithubSource(env, defaultGithubSourceRuntime(), githubObjects), + requesterGithub: createRequesterGithubSource({ + db: env.DB, + tokenKey: () => env.NUXT_TOKEN_KEY, + fetch: runtimeFetch, + now: () => Math.floor(Date.now() / 1000), + cache: githubObjects, + onReadFailure: reportGithubReadFailure, + onUnusableToken: reason => emitOperationalEvent(createWideEvent({ + operation: 'artifact-github-credential', + outcome: 'requester-token-unusable', + reason, + })), + }), bucket: env.PUBLIC_ARTIFACTS, signer: createArtifactSigner(env.ARTIFACT_SIGNER), trustedRoot: parseTrustedRoot(env.ARTIFACT_TRUSTED_ROOT_JSON, Math.floor(Date.now() / 1000)), diff --git a/layers/artifact-delivery/server/utils/request-resolution.ts b/layers/artifact-delivery/server/utils/request-resolution.ts index d9556bb6..7a775eb2 100644 --- a/layers/artifact-delivery/server/utils/request-resolution.ts +++ b/layers/artifact-delivery/server/utils/request-resolution.ts @@ -3,6 +3,7 @@ import type { SourceRequest } from '../schemas/contracts' import type { FetchAdmittedSkillIdentity } from './admitted-identity' import type { CreateResolutionResult } from './state' import { admittedSourceRequest } from './admitted-identity' +import { recordResolutionRequester } from './requester-github' import { createResolution, resolutionRequestIdentity } from './state' export interface ResolutionRequestDependencies { @@ -48,7 +49,16 @@ export function fetchAdmittedSkillIdentity(context?: H3EventContext): FetchAdmit */ export async function requestResolution( dependencies: ResolutionRequestDependencies, - input: { source: SourceRequest, idempotencyKey: string, access: ResolutionAccess }, + input: { + source: SourceRequest + idempotencyKey: string + access: ResolutionAccess + /** + * The signed-in account that sent a public request, or null. Its build may + * read GitHub with that account's own token once the shared quota is spent. + */ + requesterAccountId?: number | null + }, ): Promise { const accountId = input.access.visibility === 'private' ? input.access.accountId : undefined const identity = await resolutionRequestIdentity(input.source, input.idempotencyKey, accountId) @@ -56,6 +66,13 @@ export async function requestResolution( ? input.source : await admittedSourceRequest(input.source, dependencies.lookupAdmitted) const result = await createResolution(dependencies.db, source, identity, dependencies.now(), input.access) + if (result._tag === 'created' && input.access.visibility === 'public' && input.requesterAccountId) { + await recordResolutionRequester(dependencies.db, { + resolutionId: result.row.id, + accountId: input.requesterAccountId, + now: dependencies.now(), + }) + } if (result._tag !== 'idempotency-conflict' && result.row.state === 'requested') await dependencies.enqueue(result.row.id) return result diff --git a/layers/artifact-delivery/server/utils/requester-github.ts b/layers/artifact-delivery/server/utils/requester-github.ts new file mode 100644 index 00000000..dcd91ba7 --- /dev/null +++ b/layers/artifact-delivery/server/utils/requester-github.ts @@ -0,0 +1,120 @@ +import type { GithubObjectCache, GithubReadFailure, PublicGithubSourceClient } from './github-source' +import type { ResolutionRow } from './state' +import { decryptToken } from '#layers/identity/server/utils/crypto' +import { createPublicGithubSourceClient } from './github-source' + +/** + * Record the signed-in account that asked for a public Resolution. + * + * Only that Resolution's build reads it, and only after the shared GitHub + * credentials are rate limited. The row goes when the build settles. + */ +export async function recordResolutionRequester( + db: D1Database, + input: { resolutionId: string, accountId: number, now: number }, +): Promise { + await db.prepare( + `INSERT OR IGNORE INTO artifact_resolution_requesters (resolution_id, account_id, created_at) + VALUES (?1, ?2, ?3)`, + ).bind(input.resolutionId, input.accountId, input.now).run() +} + +/** Forget who asked for a Resolution. Its build has settled. */ +export async function forgetResolutionRequester(db: D1Database, resolutionId: string): Promise { + await db.prepare(`DELETE FROM artifact_resolution_requesters WHERE resolution_id = ?1`).bind(resolutionId).run() +} + +export interface RequesterGithubDependencies { + db: D1Database + /** + * `NUXT_TOKEN_KEY`, which encrypts the stored GitHub OAuth tokens. It is read + * only when a requester's token is needed. + */ + tokenKey: () => string + fetch: typeof globalThis.fetch + /** Unix seconds. */ + now: () => number + /** Commits and trees by SHA, shared with the build's own client. */ + cache?: GithubObjectCache + onReadFailure?: (failure: GithubReadFailure) => void + /** Called when a stored token cannot be used. It never receives the token. */ + onUnusableToken?: (reason: string) => void +} + +/** + * The GitHub client of the account that asked for one public Resolution, or + * null when nobody signed in asked for it, or the account holds no usable + * GitHub token. + * + * The token is the account's GitHub sign-in token. Its scopes, `read:user` and + * `user:email`, read public Repositories at that account's own quota, and no + * private one. The public client also refuses any Repository GitHub reports + * as private, so a build read with it can only produce public content. + */ +export function createRequesterGithubSource( + dependencies: RequesterGithubDependencies, +): (row: ResolutionRow) => Promise { + return async (row) => { + if (row.visibility !== 'public') + return null + const stored = await dependencies.db.prepare( + `SELECT u.github_token_encrypted, u.github_token_expires_at + FROM artifact_resolution_requesters r + JOIN users u ON u.id = r.account_id + WHERE r.resolution_id = ?1 + LIMIT 1`, + ).bind(row.id).first<{ github_token_encrypted: string | null, github_token_expires_at: number | null }>() + if (!stored?.github_token_encrypted) + return null + if (stored.github_token_expires_at !== null && stored.github_token_expires_at <= dependencies.now() + 60) + return null + const token = await decryptToken(stored.github_token_encrypted, dependencies.tokenKey()).then( + value => ({ _tag: 'token' as const, value }), + (error: unknown) => ({ _tag: 'unreadable' as const, reason: error instanceof Error ? error.message : String(error) }), + ) + if (token._tag === 'unreadable') { + dependencies.onUnusableToken?.(`The stored GitHub token did not decrypt: ${token.reason}`.slice(0, 200)) + return null + } + return createPublicGithubSourceClient({ + fetch: dependencies.fetch, + token: token.value, + cache: dependencies.cache, + onReadFailure: dependencies.onReadFailure, + }) + } +} + +/** + * A client that reads with the shared credential, and repeats a read the + * shared quota refused with the requester's own client. + * + * Every other answer, success or rejection, stays the shared one, so a + * requester's token is spent only when nothing else could read. + */ +export function withRequesterFallback( + shared: PublicGithubSourceClient, + requester: () => Promise, +): PublicGithubSourceClient { + let own: Promise | null = null + const ownClient = () => { + own ??= requester() + return own + } + return { + async resolve(request) { + const first = await shared.resolve(request) + if (first._tag !== 'rejected' || first.code !== 'RATE_LIMITED') + return first + const client = await ownClient() + return client ? await client.resolve(request) : first + }, + async load(source) { + const first = await shared.load(source) + if (first._tag !== 'rejected' || first.code !== 'RATE_LIMITED') + return first + const client = await ownClient() + return client ? await client.load(source) : first + }, + } +} diff --git a/layers/identity/server/utils/account-deletion.ts b/layers/identity/server/utils/account-deletion.ts index 06e82f0c..488a2f59 100644 --- a/layers/identity/server/utils/account-deletion.ts +++ b/layers/identity/server/utils/account-deletion.ts @@ -27,6 +27,7 @@ const ACCOUNT_ROW_DELETES = [ `DELETE FROM digest_runs WHERE user_id = ?1`, `DELETE FROM weekly_runs WHERE user_id = ?1`, `DELETE FROM email_preference_events WHERE user_id = ?1`, + `DELETE FROM artifact_resolution_requesters WHERE account_id = ?1`, `DELETE FROM artifact_download_grants WHERE account_id = ?1`, `DELETE FROM private_artifact_attestations WHERE account_id = ?1`, `DELETE FROM private_artifacts WHERE account_id = ?1`, diff --git a/layers/identity/server/utils/data-retention.ts b/layers/identity/server/utils/data-retention.ts index 6e5241eb..cc795f30 100644 --- a/layers/identity/server/utils/data-retention.ts +++ b/layers/identity/server/utils/data-retention.ts @@ -14,6 +14,11 @@ export const RETENTION_SECONDS = { cliSignIn: DAY_SECONDS, /** Delivery history and preference changes support three months of reporting. */ emailHistory: 90 * DAY_SECONDS, + /** + * The account that asked for a public Artifact build. The build deletes it + * when it settles; this removes one a dead build left behind. + */ + runRequester: DAY_SECONDS, } as const export interface RetentionPurgeResult { @@ -23,6 +28,7 @@ export interface RetentionPurgeResult { digestRuns: number weeklyRuns: number emailPreferenceEvents: number + runRequesters: number } /** @@ -40,6 +46,7 @@ export async function purgeRetainedPersonalData( const endedTokenCutoff = now - RETENTION_SECONDS.endedCliToken const signInCutoff = now - RETENTION_SECONDS.cliSignIn const historyCutoff = now - RETENTION_SECONDS.emailHistory + const requesterCutoff = now - RETENTION_SECONDS.runRequester const results = await db.batch([ db.prepare( @@ -62,6 +69,7 @@ export async function purgeRetainedPersonalData( ).bind(historyCutoff), db.prepare(`DELETE FROM weekly_runs WHERE window_end <= ?1`).bind(historyCutoff), db.prepare(`DELETE FROM email_preference_events WHERE occurred_at <= ?1`).bind(historyCutoff), + db.prepare(`DELETE FROM artifact_resolution_requesters WHERE created_at <= ?1`).bind(requesterCutoff), ]) const changes = results.map(result => result.meta.changes) @@ -72,5 +80,6 @@ export async function purgeRetainedPersonalData( digestRuns: changes[3] ?? 0, weeklyRuns: changes[4] ?? 0, emailPreferenceEvents: changes[5] ?? 0, + runRequesters: changes[6] ?? 0, } } diff --git a/migrations/0144_artifact_resolution_requesters.sql b/migrations/0144_artifact_resolution_requesters.sql new file mode 100644 index 00000000..246075cd --- /dev/null +++ b/migrations/0144_artifact_resolution_requesters.sql @@ -0,0 +1,13 @@ +-- The signed-in account that asked for a public Resolution, kept only while +-- its build runs. When the shared GitHub credentials are rate limited, the +-- build reads GitHub with this account's own token, for this build only. +-- The build deletes the row when it settles; the daily personal data purge +-- removes any row a dead build left behind. +CREATE TABLE artifact_resolution_requesters ( + resolution_id TEXT PRIMARY KEY REFERENCES artifact_resolutions(id) ON DELETE CASCADE, + account_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at INTEGER NOT NULL +); + +CREATE INDEX idx_artifact_resolution_requesters_created_at + ON artifact_resolution_requesters(created_at); diff --git a/test/unit/account-deletion.test.ts b/test/unit/account-deletion.test.ts index 66827b02..e6fc9094 100644 --- a/test/unit/account-deletion.test.ts +++ b/test/unit/account-deletion.test.ts @@ -190,6 +190,14 @@ function seedAccount(raw: DatabaseSync, account: Account, githubToken: string | id, installationId, ) + run( + `INSERT INTO artifact_resolutions ( + id, request_fingerprint, state, requested_owner, requested_repository, selector_type, selector_value, visibility + ) VALUES (?, ?, 'resolving', 'acme', 'skills', 'named-skill', 'tdd', 'public')`, + `public-run-${login}`, + `public-fingerprint-${login}`, + ) + run(`INSERT INTO artifact_resolution_requesters (resolution_id, account_id, created_at) VALUES (?, ?, ?)`, `public-run-${login}`, id, NOW) run(`INSERT INTO artifact_check_results (resolution_id, name, version, outcome, required) VALUES (?, 'skill-structure', '1', 'pass', 1)`, resolutionId) run( `INSERT INTO artifacts (id, content_sha256, content_bytes, format, r2_key) VALUES (?, ?, 128, 'skilld-tar-v1', ?)`, diff --git a/test/unit/artifact-build-reuse.test.ts b/test/unit/artifact-build-reuse.test.ts index 6725b405..67831896 100644 --- a/test/unit/artifact-build-reuse.test.ts +++ b/test/unit/artifact-build-reuse.test.ts @@ -43,6 +43,7 @@ const MIGRATIONS = [ 'migrations/0112_private_artifact_keys.sql', 'migrations/0122_artifact_resolution_retry_after.sql', 'migrations/0131_artifact_build_reuse.sql', + 'migrations/0144_artifact_resolution_requesters.sql', ] const COMMIT = '0123456789abcdef0123456789abcdef01234567' const NEXT_COMMIT = 'fedcba9876543210fedcba9876543210fedcba98' diff --git a/test/unit/artifact-delivery.test.ts b/test/unit/artifact-delivery.test.ts index 6fc5aaea..ea838500 100644 --- a/test/unit/artifact-delivery.test.ts +++ b/test/unit/artifact-delivery.test.ts @@ -41,6 +41,7 @@ const ARTIFACT_MIGRATIONS = [ 'migrations/0111_github_app_delivery.sql', 'migrations/0112_private_artifact_keys.sql', 'migrations/0122_artifact_resolution_retry_after.sql', + 'migrations/0144_artifact_resolution_requesters.sql', ] const sourceRequest: SourceRequest = { provider: 'github', diff --git a/test/unit/artifact-rate-limit.test.ts b/test/unit/artifact-rate-limit.test.ts index 133cec2e..873a817e 100644 --- a/test/unit/artifact-rate-limit.test.ts +++ b/test/unit/artifact-rate-limit.test.ts @@ -22,6 +22,7 @@ const ARTIFACT_MIGRATIONS = [ 'migrations/0111_github_app_delivery.sql', 'migrations/0112_private_artifact_keys.sql', 'migrations/0122_artifact_resolution_retry_after.sql', + 'migrations/0144_artifact_resolution_requesters.sql', ] const resolutionRequest: SourceRequest = { provider: 'github', diff --git a/test/unit/artifact-requester-github.test.ts b/test/unit/artifact-requester-github.test.ts new file mode 100644 index 00000000..3ef2c0a6 --- /dev/null +++ b/test/unit/artifact-requester-github.test.ts @@ -0,0 +1,177 @@ +import type { SourceRequest } from '../../layers/artifact-delivery/server/schemas/contracts' +import type { ArtifactSigner } from '../../layers/artifact-delivery/server/utils/attestation' +import type { ArtifactBuildDependencies } from '../../layers/artifact-delivery/server/utils/build' +import type { PublicGithubSourceClient, SourceRejection } from '../../layers/artifact-delivery/server/utils/github-source' +import type { TrustedRoot } from '../../layers/artifact-delivery/server/utils/trusted-root' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { processArtifactBuild } from '../../layers/artifact-delivery/server/utils/build' +import { ARTIFACT_BUILD_QUEUE_NAME, consumeArtifactBuildBatch } from '../../layers/artifact-delivery/server/utils/queue' +import { requestResolution } from '../../layers/artifact-delivery/server/utils/request-resolution' +import { createRequesterGithubSource } from '../../layers/artifact-delivery/server/utils/requester-github' +import { getResolution } from '../../layers/artifact-delivery/server/utils/state' +import { encryptToken } from '../../layers/identity/server/utils/crypto' +import { purgeRetainedPersonalData } from '../../layers/identity/server/utils/data-retention' +import { allMigrations, createSqliteD1 } from './helpers/d1-sqlite' + +// The shared GitHub buckets run out each hour. A signed-in run can still read +// GitHub with its own account's token, which has its own quota, for its own +// build only. +const NOW = 1_791_300_000 +const RESET_AT = NOW + 600 +const TOKEN_KEY = btoa(String.fromCharCode(...new Uint8Array(32).fill(7))) +const ALICE = 9101 +const BOB = 9102 +const request: SourceRequest = { + provider: 'github', + owner: 'skilld-dev', + repository: 'skills', + selector: { type: 'path', path: 'skills/demo' }, +} + +describe('a signed-in run when the shared GitHub credential is rate limited', () => { + let fixture: ReturnType + + beforeEach(async () => { + fixture = createSqliteD1(allMigrations()) + const insert = fixture.raw.prepare( + `INSERT INTO users (id, github_id, login, github_token_encrypted, created_at, last_login_at) + VALUES (?, ?, ?, ?, ?, ?)`, + ) + insert.run(ALICE, 900_001, 'alice', await encryptToken('gho_alice', TOKEN_KEY), NOW, NOW) + insert.run(BOB, 900_002, 'bob', await encryptToken('gho_bob', TOKEN_KEY), NOW, NOW) + }) + + afterEach(() => fixture.close()) + + it('reads GitHub with the requester\'s own token', async () => { + const github = userGithub() + const id = await requested('alice-run-0000001', ALICE) + + await processArtifactBuild(dependencies(github), id) + + expect(github.authorizations).toContain('Bearer gho_alice') + expect(github.authorizations).not.toContain('Bearer gho_bob') + // The requester's read reached GitHub, which has no such Repository. + expect(await getResolution(fixture.db, id)).toMatchObject({ state: 'failed', error_code: 'SOURCE_NOT_FOUND' }) + }) + + it('keeps RATE_LIMITED for an anonymous run, and reads with no user token', async () => { + const github = userGithub() + const id = await requested('anonymous-run-001', null) + + await processArtifactBuild(dependencies(github), id) + + expect(github.authorizations).toEqual([]) + expect(await getResolution(fixture.db, id)).toMatchObject({ state: 'failed', error_code: 'RATE_LIMITED' }) + }) + + it('never reads one user\'s build with another user\'s token', async () => { + const github = userGithub() + await requested('alice-run-0000002', ALICE) + const bobRun = await requested('bob-run-000000001', BOB) + const anonymousRun = await requested('anonymous-run-002', null) + + await processArtifactBuild(dependencies(github), bobRun) + await processArtifactBuild(dependencies(github), anonymousRun) + + expect(github.authorizations.length).toBeGreaterThan(0) + expect(github.authorizations.every(value => value === 'Bearer gho_bob')).toBe(true) + }) + + it('keeps the shared credential while it has quota', async () => { + const github = userGithub() + const shared = sharedGithub({ code: 'SOURCE_NOT_FOUND' }) + const id = await requested('alice-run-0000003', ALICE) + + await processArtifactBuild(dependencies(github, shared), id) + + expect(github.authorizations).toEqual([]) + expect(shared.resolve).toHaveBeenCalledOnce() + }) + + it('reports RATE_LIMITED when the requester has no stored GitHub token', async () => { + fixture.raw.prepare(`UPDATE users SET github_token_encrypted = NULL WHERE id = ?`).run(ALICE) + const github = userGithub() + const id = await requested('alice-run-0000004', ALICE) + + await processArtifactBuild(dependencies(github), id) + + expect(github.authorizations).toEqual([]) + expect(await getResolution(fixture.db, id)).toMatchObject({ state: 'failed', error_code: 'RATE_LIMITED' }) + }) + + it('forgets who asked once the build settles', async () => { + const github = userGithub() + const id = await requested('alice-run-0000005', ALICE) + const message = { id: 'message-1', attempts: 1, body: { version: 1, resolutionId: id }, ack: vi.fn(), retry: vi.fn() } + + await consumeArtifactBuildBatch( + {} as Cloudflare.Env, + { queue: ARTIFACT_BUILD_QUEUE_NAME, messages: [message] } as unknown as Parameters[1], + () => dependencies(github), + ) + + expect(message.ack).toHaveBeenCalledOnce() + expect(requesters()).toEqual([]) + }) + + it('purges a requester a dead build left behind after a day', async () => { + const stale = await requested('alice-run-0000006', ALICE) + fixture.raw.prepare(`UPDATE artifact_resolution_requesters SET created_at = ? WHERE resolution_id = ?`).run(NOW - 86_401, stale) + const fresh = await requested('alice-run-0000007', ALICE) + + await purgeRetainedPersonalData(fixture.db, NOW) + + expect(requesters()).toEqual([fresh]) + }) + + function requesters(): unknown[] { + return fixture.raw.prepare(`SELECT resolution_id FROM artifact_resolution_requesters`).all().map(row => Object.values(row)[0]) + } + + async function requested(idempotencyKey: string, requesterAccountId: number | null): Promise { + const result = await requestResolution({ + db: fixture.db, + lookupAdmitted: async () => null, + enqueue: async () => {}, + now: () => NOW, + }, { source: request, idempotencyKey, access: { visibility: 'public' }, requesterAccountId }) + if (result._tag === 'idempotency-conflict') + throw new Error('Test Resolution conflicted') + return result.row.id + } + + function dependencies(github: UserGithub, shared: PublicGithubSourceClient = sharedGithub({ code: 'RATE_LIMITED' })): ArtifactBuildDependencies { + return { + db: fixture.db, + github: shared, + requesterGithub: createRequesterGithubSource({ db: fixture.db, tokenKey: () => TOKEN_KEY, fetch: github.fetch, now: () => NOW }), + bucket: {} as R2Bucket, + signer: {} as ArtifactSigner, + trustedRoot: {} as TrustedRoot, + now: () => NOW, + } + } +}) + +function sharedGithub(rejection: { code: SourceRejection['code'] }): PublicGithubSourceClient & { resolve: ReturnType } { + const rejected: SourceRejection = rejection.code === 'RATE_LIMITED' + ? { _tag: 'rejected', code: 'RATE_LIMITED', summary: 'GitHub refused the read: its rate limit is spent.', findings: [], retryAfterSeconds: RESET_AT } + : { _tag: 'rejected', code: rejection.code, summary: 'The Repository was not found.', findings: [] } + return { resolve: vi.fn(async () => rejected), load: vi.fn(async () => rejected) } +} + +interface UserGithub { + fetch: typeof fetch + authorizations: Array +} + +/** GitHub as a user token sees it: it has quota, and no such Repository. */ +function userGithub(): UserGithub { + const authorizations: UserGithub['authorizations'] = [] + const fetcher = async (_input: RequestInfo | URL, init?: RequestInit) => { + authorizations.push(new Headers(init?.headers).get('authorization')) + return Response.json({ message: 'Not Found' }, { status: 404 }) + } + return { fetch: fetcher as typeof fetch, authorizations } +} diff --git a/test/unit/skill-run-sweep.test.ts b/test/unit/skill-run-sweep.test.ts index 55302f16..417c5ecf 100644 --- a/test/unit/skill-run-sweep.test.ts +++ b/test/unit/skill-run-sweep.test.ts @@ -17,6 +17,7 @@ const MIGRATIONS = [ 'migrations/0122_artifact_resolution_retry_after.sql', 'migrations/0142_artifact_run_checks.sql', 'migrations/0143_artifact_run_check_streak.sql', + 'migrations/0144_artifact_resolution_requesters.sql', ] const NOW = 1_791_000_000 const archify: RunnableSkill = { owner: 'tt-a1i', repository: 'archify', name: 'archify' }