diff --git a/action.yml b/action.yml index 56dac7e..0d839a0 100644 --- a/action.yml +++ b/action.yml @@ -21,6 +21,10 @@ inputs: description: Apply changes instead of dry-running required: false default: 'false' + onboard-only: + description: Create only a missing default ruleset after privileged preflight; never modify existing protections + required: false + default: 'false' allow-drift: description: Report drift without failing the plan; operational errors still fail required: false @@ -48,6 +52,7 @@ runs: TARGET_ORGANIZATION: ${{ inputs.organization }} CONFIG_PATH: ${{ inputs.config }} APPLY: ${{ inputs.apply }} + ONBOARD_ONLY: ${{ inputs.onboard-only }} ALLOW_DRIFT: ${{ inputs.allow-drift }} PUBLIC_DISCOVERY: ${{ inputs.public-discovery }} working-directory: ${{ github.action_path }} @@ -66,6 +71,9 @@ runs: if [[ "$APPLY" == "true" ]]; then args+=(--apply) fi + if [[ "$ONBOARD_ONLY" == "true" ]]; then + args+=(--onboard-only) + fi if [[ "$ALLOW_DRIFT" == "true" ]]; then args+=(--allow-drift) fi diff --git a/src/cli-runner.ts b/src/cli-runner.ts index 2d2a46f..cdabb33 100644 --- a/src/cli-runner.ts +++ b/src/cli-runner.ts @@ -8,6 +8,7 @@ import { type GovernanceConfig, } from './config.ts'; import type { GovernanceClient } from './governance.ts'; +import { onboardRepository, type OnboardingClient } from './onboarding.ts'; import { planOrganization, planRepository, @@ -39,6 +40,7 @@ export async function runCli( const repositoryArgument = option(args, '--repo'); const configPath = option(args, '--config'); const apply = args.includes('--apply'); + const onboardOnly = args.includes('--onboard-only'); const allowDrift = args.includes('--allow-drift'); const publicDiscovery = args.includes('--public-discovery'); const token = environment.GITHUB_TOKEN; @@ -53,6 +55,11 @@ export async function runCli( return 2; } + if (onboardOnly && (!apply || publicDiscovery)) { + output.error('--onboard-only requires --apply and an installation-scoped token'); + return 2; + } + if (!token) { output.error('GITHUB_TOKEN is required'); return 2; @@ -79,6 +86,50 @@ export async function runCli( topics: string[]; }) => resolveRepositoryMetadata(config, repository); + if (onboardOnly) { + // The onboarding mode is deliberately narrower than regular --apply. + // It creates only a missing common default; existing rulesets and metadata + // are never updated. Fail if caller configuration is ambiguous. + const defaults = config.defaults; + if (defaults?.policies?.length !== 1 || (defaults.rulesets?.length ?? 0) !== 0) { + output.error('Safe onboarding requires exactly one named default policy'); + return 2; + } + const defaultPolicy = config.policies?.[defaults.policies[0]!]; + if (!defaultPolicy) { + output.error('Default policy is not defined'); + return 2; + } + if (!('classicProtection' in client) || typeof client.classicProtection !== 'function') { + output.error('A privileged branch-protection preflight is required for onboarding'); + return 2; + } + const provisioner = client as OnboardingClient; + let repos; + if (repositoryArgument) { + const parts = repositoryArgument.split('/'); + if (parts.length !== 2 || !parts[0] || !parts[1]) { + output.error('--repo must use OWNER/REPO'); + return 2; + } + repos = [await client.getRepository(parts[0], parts[1])]; + } else { + repos = await client.listManagedRepositories(organization!); + } + let reviewNeeded = false; + for (const repo of repos) { + const selected = (await resolveRulesets(repo)).find((rule) => rule.name === defaultPolicy.name); + if (!selected) { + output.error(`Missing default policy for ${repo.owner}/${repo.name}`); + return 2; + } + const result = await onboardRepository(provisioner, repo, selected); + output.log(`${result.status.toUpperCase()} ${result.repository}: ${result.reason}`); + if (result.status === 'review') reviewNeeded = true; + } + return reviewNeeded ? 1 : 0; + } + if (repositoryArgument) { const [owner, repository, ...extra] = repositoryArgument.split('/'); if (!owner || !repository || extra.length > 0) { diff --git a/src/config-validation.ts b/src/config-validation.ts index 67f9e30..c49a55d 100644 --- a/src/config-validation.ts +++ b/src/config-validation.ts @@ -233,7 +233,7 @@ function validateRule(value: unknown, path: string): RulesetRule { const record = expectRecord(value, path); const type = expectString(record.type, `${path}.type`); - if (type === 'deletion' || type === 'non_fast_forward') { + if (type === 'deletion' || type === 'non_fast_forward' || type === 'required_signatures') { rejectUnknownKeys(record, path, ['type']); return { type }; } diff --git a/src/github-client.ts b/src/github-client.ts index 5d99a18..8d1627b 100644 --- a/src/github-client.ts +++ b/src/github-client.ts @@ -24,6 +24,7 @@ type GitHubRepository = { description?: unknown; homepage?: unknown; topics?: unknown; + default_branch?: unknown; owner?: { login?: unknown; }; @@ -94,6 +95,7 @@ export class GitHubClient description: typeof description === 'string' ? description : null, homepage: typeof homepage === 'string' && homepage !== '' ? homepage : null, topics: Array.isArray(topics) ? topics as string[] : [], + ...(typeof data.default_branch === 'string' ? { defaultBranch: data.default_branch } : {}), }; } @@ -136,6 +138,7 @@ export class GitHubClient description: typeof description === 'string' ? description : null, homepage: typeof homepage === 'string' && homepage !== '' ? homepage : null, topics: Array.isArray(topics) && topics.every((topic) => typeof topic === 'string') ? topics as string[] : [], + ...(typeof repository.default_branch === 'string' ? { defaultBranch: repository.default_branch } : {}), }); } } @@ -207,6 +210,26 @@ export class GitHubClient return true; } + /** Privileged preflight for safe first-time onboarding. Never delete legacy settings. */ + async classicProtection( + owner: string, + repository: string, + branch: string, + ): Promise<'absent' | 'present' | 'uninitialized'> { + const base = `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}/branches/${encodeURIComponent(branch)}`; + const branchResponse = await this.fetchImpl(this.url(base), { headers: this.headers() }); + if (branchResponse.status === 404) return 'uninitialized'; + if (!branchResponse.ok) { + throw new Error(`Cannot verify default branch for ${owner}/${repository}: HTTP ${branchResponse.status}`); + } + const protectionResponse = await this.fetchImpl(this.url(`${base}/protection`), { headers: this.headers() }); + if (protectionResponse.status === 404) return 'absent'; + if (!protectionResponse.ok) { + throw new Error(`Cannot inspect classic branch protection for ${owner}/${repository}: HTTP ${protectionResponse.status}`); + } + return 'present'; + } + async list( owner: string, repository: string, @@ -392,7 +415,7 @@ function parseRule(value: unknown): RulesetRule { throw new Error('Invalid ruleset rule from GitHub'); } - if (value.type === 'deletion' || value.type === 'non_fast_forward') { + if (value.type === 'deletion' || value.type === 'non_fast_forward' || value.type === 'required_signatures') { return { type: value.type }; } diff --git a/src/onboarding.ts b/src/onboarding.ts new file mode 100644 index 0000000..9ad03bf --- /dev/null +++ b/src/onboarding.ts @@ -0,0 +1,78 @@ +// SPDX-FileCopyrightText: 2026 LibreCode coop and contributors +// SPDX-License-Identifier: AGPL-3.0-or-later + +import type { GovernanceClient } from './governance.ts'; +import type { RepositoryMetadata } from './repository-classifier.ts'; +import type { RepositoryRuleset } from './types.ts'; +import { rulesetsEqual } from './ruleset-normalizer.ts'; + +export interface OnboardingClient extends GovernanceClient { + classicProtection( + owner: string, + repository: string, + branch: string, + ): Promise<'absent' | 'present' | 'uninitialized'>; +} + +export type OnboardingResult = { + repository: string; + status: 'created' | 'unchanged' | 'review' | 'skipped'; + reason: string; +}; + +/** + * Only install a missing, already-classified organization default ruleset. + * Never update existing rulesets, repository metadata, or legacy protection. + */ +export async function onboardRepository( + client: OnboardingClient, + repo: RepositoryMetadata, + expected: RepositoryRuleset, +): Promise { + const repository = `${repo.owner}/${repo.name}`; + if (repo.archived || repo.visibility !== 'public') { + return { repository, status: 'skipped', reason: 'only public, non-archived repositories are eligible' }; + } + if (!repo.defaultBranch || !/^[A-Za-z0-9._/-]+$/.test(repo.defaultBranch)) { + return { repository, status: 'review', reason: 'default branch could not be verified' }; + } + // Inspect legacy protection even on repositories whose common ruleset is + // already present: overlapping classic protection can defeat its bypass. + const classic = await client.classicProtection(repo.owner, repo.name, repo.defaultBranch); + if (classic !== 'absent') { + return { + repository, + status: 'review', + reason: classic === 'present' ? 'classic branch protection requires manual migration' : 'default branch not initialized', + }; + } + const current = await client.list(repo.owner, repo.name); + // A conditional direct-push exception (e.g. the Nextcloud translation bot) + // must be honored by EVERY active rule protecting the default branch. + const directPushActors = expected.bypass_actors.filter( + (actor) => actor.actor_type === 'User' && actor.bypass_mode === 'always', + ); + for (const rule of current) { + if (rule.name === expected.name || rule.enforcement !== 'active') continue; + if (directPushActors.some((actor) => !rule.bypass_actors.some( + (candidate) => candidate.actor_type === actor.actor_type && + candidate.actor_id === actor.actor_id && candidate.bypass_mode === 'always', + ))) { + return { repository, status: 'review', reason: `active ruleset ${rule.name} does not permit required direct-push actor` }; + } + } + const matching = current.find((rule) => rule.name === expected.name); + if (matching) { + if (matching.bypass_actors_redacted || !rulesetsEqual(matching, expected)) { + return { repository, status: 'review', reason: 'existing common ruleset differs or has hidden bypass actors' }; + } + return { repository, status: 'unchanged', reason: 'common ruleset already present' }; + } + // A repository with any other ruleset needs human inspection, including the + // case of another active CI/DCO policy that might block a translation bot. + if (current.length > 0) { + return { repository, status: 'review', reason: 'existing additional rulesets require review' }; + } + await client.create(repo.owner, repo.name, expected); + return { repository, status: 'created', reason: 'default organization ruleset created' }; +} diff --git a/src/repository-classifier.ts b/src/repository-classifier.ts index c090c2b..aac6ab6 100644 --- a/src/repository-classifier.ts +++ b/src/repository-classifier.ts @@ -13,4 +13,5 @@ export type RepositoryMetadata = { description: string | null; homepage: string | null; topics: string[]; + defaultBranch?: string; }; diff --git a/src/types.ts b/src/types.ts index 64066af..cde47a9 100644 --- a/src/types.ts +++ b/src/types.ts @@ -29,7 +29,7 @@ export type RequiredStatusChecksRule = { }; export type SimpleRule = { - type: 'deletion' | 'non_fast_forward'; + type: 'deletion' | 'non_fast_forward' | 'required_signatures'; }; export type RulesetRule = PullRequestRule | RequiredStatusChecksRule | SimpleRule; diff --git a/tests/cli-runner.test.ts b/tests/cli-runner.test.ts index 259102d..d8ace2b 100644 --- a/tests/cli-runner.test.ts +++ b/tests/cli-runner.test.ts @@ -260,6 +260,46 @@ describe('runCli', () => { expect(messages.some((m) => m.startsWith('DRIFT '))).toBe(false); }); + it('refuses onboarding without explicit privileged apply', async () => { + const errors: string[] = []; + const code = await runCli( + ['--repo', 'ExampleOrg/project', '--onboard-only'], + { GITHUB_TOKEN: 'token' }, + () => { throw new Error('GitHub must not be contacted'); }, + { log: () => undefined, error: (message) => errors.push(message) }, + configLoader, + ); + expect(code).toBe(2); + expect(errors).toEqual(['--onboard-only requires --apply and an installation-scoped token']); + }); + + it('never creates product-specific CI rules during default-only onboarding', async () => { + const events: string[] = []; + class OnboardFake extends FakeClient { + override async getRepository(owner: string, repository: string): Promise { + return { ...await super.getRepository(owner, repository), defaultBranch: 'main' }; + } + async classicProtection() { events.push('preflight'); return 'absent' as const; } + override async create(...args: unknown[]) { + const ruleset = args[2] as RepositoryRuleset; + events.push(`create:${ruleset.name}`); + } + override async update() { throw new Error('must not update existing policy'); } + override async updateRepositoryMetadata() { throw new Error('must not change metadata'); } + } + const messages: string[] = []; + const result = await runCli( + ['--repo', 'ExampleOrg/project', '--onboard-only', '--apply'], + { GITHUB_TOKEN: 'token' }, + () => new OnboardFake(), + { log: (message) => messages.push(message), error: () => undefined }, + configLoader, + ); + expect(result).toBe(0); + expect(events).toEqual(['preflight', 'create:Protect branches']); + expect(messages).toEqual(['CREATED ExampleOrg/project: default organization ruleset created']); + }); + it('uses apply mode only when explicitly requested', async () => { const messages: string[] = []; diff --git a/tests/config-validation.test.ts b/tests/config-validation.test.ts index 7cce7bd..fb6ffe8 100644 --- a/tests/config-validation.test.ts +++ b/tests/config-validation.test.ts @@ -69,6 +69,12 @@ describe('validateGovernanceConfig', () => { ); }); + it('accepts an explicit signed-commit ruleset', () => { + const config = structuredClone(validConfig) as any; + config.policies.protected.rules.push({ type: 'required_signatures' }); + expect(validateGovernanceConfig(config)).toEqual(config); + }); + it('rejects unsupported rule types', () => { const config = structuredClone(validConfig) as any; config.policies.protected.rules = [{ type: 'creation' }]; diff --git a/tests/github-client.test.ts b/tests/github-client.test.ts index a9db78c..c038ce1 100644 --- a/tests/github-client.test.ts +++ b/tests/github-client.test.ts @@ -24,6 +24,55 @@ function fakeFetch(expectations: ExpectedRequest[]): typeof fetch { }; } +describe('GitHubClient privileged classic protection preflight', () => { + for (const scenario of [ + { name: 'legacy protection exists', protection: Response.json({ url: 'https://api.github.test/example' }), result: 'present' }, + { name: 'legacy protection absent', protection: new Response(null, { status: 404 }), result: 'absent' }, + ] as const) { + it(scenario.name, async () => { + const requests: ExpectedRequest[] = [ + { + url: 'https://api.github.test/repos/ExampleOrg/project/branches/main', + response: Response.json({ name: 'main' }), + }, + { + url: 'https://api.github.test/repos/ExampleOrg/project/branches/main/protection', + response: scenario.protection, + }, + ]; + const client = new GitHubClient('token', fakeFetch(requests), 'https://api.github.test'); + expect(await client.classicProtection('ExampleOrg', 'project', 'main')).toBe(scenario.result); + expect(requests).toHaveLength(0); + }); + } + + it('does not provision a repository without an initialized default branch', async () => { + const requests: ExpectedRequest[] = [{ + url: 'https://api.github.test/repos/ExampleOrg/empty/branches/main', + response: new Response(null, { status: 404 }), + }]; + const client = new GitHubClient('token', fakeFetch(requests), 'https://api.github.test'); + expect(await client.classicProtection('ExampleOrg', 'empty', 'main')).toBe('uninitialized'); + expect(requests).toHaveLength(0); + }); + + it('fails closed when the branch protection endpoint rejects permission', async () => { + const requests: ExpectedRequest[] = [ + { + url: 'https://api.github.test/repos/ExampleOrg/project/branches/main', + response: Response.json({ name: 'main' }), + }, + { + url: 'https://api.github.test/repos/ExampleOrg/project/branches/main/protection', + response: new Response(null, { status: 403 }), + }, + ]; + const client = new GitHubClient('token', fakeFetch(requests), 'https://api.github.test'); + await expect(client.classicProtection('ExampleOrg', 'project', 'main')).rejects.toThrow('HTTP 403'); + expect(requests).toHaveLength(0); + }); +}); + describe('GitHubClient', () => { it('loads repository metadata for repository-scoped execution', async () => { const requests: ExpectedRequest[] = [ @@ -211,6 +260,39 @@ describe('GitHubClient', () => { ).rejects.toThrow('HTTP 403'); }); + it('reads existing signed CI rulesets without altering their requirements', async () => { + const existing = { + ...structuredClone(protectedBranchesFixture), + id: 9, + name: 'MCP required CI and signatures', + rules: [ + { type: 'required_signatures' }, + { type: 'required_status_checks', parameters: { + required_status_checks: [{ context: 'Unit tests', integration_id: 15368 }], + strict_required_status_checks_policy: true, + do_not_enforce_on_create: false, + } }, + ], + }; + const requests: ExpectedRequest[] = [ + { + url: 'https://api.github.test/repos/ExampleOrg/project/rulesets?per_page=100', + response: Response.json([{ id: 9, source_type: 'Repository' }]), + }, + { + url: 'https://api.github.test/repos/ExampleOrg/project/rulesets/9', + response: Response.json(existing), + }, + ]; + const client = new GitHubClient('token', fakeFetch(requests), 'https://api.github.test'); + const rules = await client.list('ExampleOrg', 'project'); + expect(rules).toHaveLength(1); + expect(rules[0]!.rules.map((rule) => rule.type)).toEqual([ + 'required_signatures', 'required_status_checks', + ]); + expect(requests).toHaveLength(0); + }); + it('loads repository ruleset details and ignores organization-sourced rulesets', async () => { const rulesetResponse = { id: 7, diff --git a/tests/onboarding.test.ts b/tests/onboarding.test.ts new file mode 100644 index 0000000..afb8f29 --- /dev/null +++ b/tests/onboarding.test.ts @@ -0,0 +1,159 @@ +// SPDX-FileCopyrightText: 2026 LibreCode coop and contributors +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { describe, expect, it } from 'vitest'; +import { onboardRepository, type OnboardingClient } from '../src/onboarding.js'; +import { resolveRepositoryRulesets, type GovernanceConfig } from '../src/config.js'; +import { protectedBranchesFixture } from './ruleset-fixtures.js'; +import type { ExistingRepositoryRuleset } from '../src/ruleset-reconciler.js'; +import type { RepositoryMetadata } from '../src/repository-classifier.js'; +import type { RepositoryRuleset } from '../src/types.js'; + +const bot = { actor_id: 20296731, actor_type: 'User' as const, bypass_mode: 'always' as const }; +const normalRepo: RepositoryMetadata = { + owner: 'ExampleOrg', name: 'ordinary', visibility: 'public', archived: false, + description: null, homepage: null, topics: [], defaultBranch: 'main', +}; +const config: GovernanceConfig = { + policies: { protected: protectedBranchesFixture }, + defaults: { policies: ['protected'] }, + conditions: [{ + when: { file_exists: 'appinfo/info.xml' }, + add_bypass_actors: { protected: [bot] }, + }], +}; + +function client(options: { + existing?: ExistingRepositoryRuleset[]; + protection?: 'absent' | 'present' | 'uninitialized'; + failures?: boolean; +} = {}) { + const writes: RepositoryRuleset[] = []; + const calls: string[] = []; + const fake = { + async list() { calls.push('list'); return structuredClone(options.existing ?? []); }, + async classicProtection() { + calls.push('classicProtection'); + if (options.failures) throw new Error('Access to classic branch protection denied'); + return options.protection ?? 'absent'; + }, + async create(_owner: string, _repo: string, rule: RepositoryRuleset) { + calls.push('create'); + writes.push(structuredClone(rule)); + }, + async update() { throw new Error('Existing rulesets must never be updated during onboarding'); }, + async updateRepositoryMetadata() { throw new Error('Repository metadata must never be changed during onboarding'); }, + async exists(_owner: string, _repo: string, path: string) { return path === 'appinfo/info.xml'; }, + }; + return { fake: fake as unknown as OnboardingClient, calls, writes }; +} + +async function expected(fake: OnboardingClient, repo = normalRepo) { + return (await resolveRepositoryRulesets(config, repo, fake))[0]!; +} + +describe('privileged default-ruleset onboarding', () => { + it('creates the common policy with the Nextcloud translation-bot direct-push exception', async () => { + const { fake, writes, calls } = client(); + const desired = await expected(fake); + expect(desired.bypass_actors).toContainEqual(bot); + const result = await onboardRepository(fake, normalRepo, desired); + expect(result.status).toBe('created'); + expect(writes).toHaveLength(1); + expect(writes[0]).toEqual(desired); + expect(calls).toEqual(['classicProtection', 'list', 'create']); + }); + + it('does not grant translation-bot rights to non-Nextcloud repositories', async () => { + const { fake, writes } = client(); + fake.exists = async () => false; + const desired = await expected(fake); + expect(desired.bypass_actors).not.toContainEqual(bot); + await onboardRepository(fake, normalRepo, desired); + expect(writes).toHaveLength(1); + expect(writes[0]!.bypass_actors).not.toContainEqual(bot); + }); + + it('never changes an existing common ruleset with missing bot bypass', async () => { + const current = { ...structuredClone(protectedBranchesFixture), id: 1 }; + const { fake, writes } = client({ existing: [current] }); + const result = await onboardRepository(fake, normalRepo, await expected(fake)); + expect(result.status).toBe('review'); + expect(result.reason).toContain('differs'); + expect(writes).toHaveLength(0); + }); + + it('accepts a Nextcloud app with a separate DCO ruleset that grants bot bypass', async () => { + const { fake, writes } = client(); + const desired = await expected(fake); + const dco = { ...structuredClone(protectedBranchesFixture), name: 'Require DCO', id: 10, + bypass_actors: [bot], rules: [] } as ExistingRepositoryRuleset; + const current = { ...structuredClone(desired), id: 11 }; + const originalList = fake.list; + fake.list = async (...args) => { await originalList(...args); return [current, dco]; }; + const result = await onboardRepository(fake, normalRepo, desired); + expect(result.status).toBe('unchanged'); + expect(writes).toHaveLength(0); + }); + + it('rejects a separate active DCO rule that would block the same bot', async () => { + const { fake, writes } = client(); + const desired = await expected(fake); + const current = { ...structuredClone(desired), id: 11 }; + const dco = { ...structuredClone(protectedBranchesFixture), name: 'Require DCO', id: 10, + bypass_actors: [] } as ExistingRepositoryRuleset; + fake.list = async () => [current, dco]; + const result = await onboardRepository(fake, normalRepo, desired); + expect(result.status).toBe('review'); + expect(result.reason).toContain('direct-push actor'); + expect(writes).toHaveLength(0); + }); + + it('ignores an inactive CI rule but does not modify it', async () => { + const { fake, writes } = client(); + const desired = await expected(fake); + fake.list = async () => [ + { ...structuredClone(desired), id: 11 }, + { ...structuredClone(protectedBranchesFixture), name: 'CI disabled', enforcement: 'disabled', id: 12, bypass_actors: [] }, + ]; + expect((await onboardRepository(fake, normalRepo, desired)).status).toBe('unchanged'); + expect(writes).toHaveLength(0); + }); + + it.each(['present', 'uninitialized'] as const)('never changes %s classic/default branch state', async (protection) => { + const { fake, writes, calls } = client({ protection }); + const result = await onboardRepository(fake, normalRepo, await expected(fake)); + expect(result.status).toBe('review'); + expect(writes).toHaveLength(0); + expect(calls).not.toContain('create'); + }); + + it('detects legacy protection even if the common ruleset is already present', async () => { + const { fake, writes } = client({ protection: 'present', existing: [{ ...structuredClone(protectedBranchesFixture), id: 1 }] }); + expect((await onboardRepository(fake, normalRepo, await expected(fake))).status).toBe('review'); + expect(writes).toHaveLength(0); + }); + + it('requires manual review if another ruleset already exists', async () => { + const { fake, writes } = client({ existing: [{ ...structuredClone(protectedBranchesFixture), name: 'Custom CI', id: 5 }] }); + const result = await onboardRepository(fake, normalRepo, await expected(fake)); + expect(result.status).toBe('review'); + expect(writes).toHaveLength(0); + }); + + it('skips private and archived repositories', async () => { + for (const repo of [{ ...normalRepo, archived: true }, { ...normalRepo, visibility: 'private' as const }]) { + const { fake, writes, calls } = client(); + const result = await onboardRepository(fake, repo, await expected(fake, repo)); + expect(result.status).toBe('skipped'); + expect(calls).toEqual([]); + expect(writes).toHaveLength(0); + } + }); + + it('fails closed when it cannot inspect branch protection', async () => { + const { fake, writes } = client({ failures: true }); + await expect(onboardRepository(fake, normalRepo, await expected(fake))).rejects.toThrow('denied'); + expect(writes).toHaveLength(0); + }); +});