/** * End-to-end test of private repositories over spaces: two Node PDS * instances, a stub PLC directory, and real git subprocesses driving the * helper through the space form of the remote URL. * * Alice hosts the repository in a space she owns. She pushes and clones with * her own session. Bob is a space member on his own PDS; his clone runs the * whole credential chain: a session on his PDS, a delegation token, a space * credential from Alice's PDS, and DPoP-proofed reads. * * Git runs asynchronously for the same reason as in git-remote.test.js: both * PDS instances live on this process's event loop. */ import { execFile } from 'node:child_process'; import { randomBytes } from 'node:crypto'; import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync, } from 'node:fs'; import { createServer as createHttpServer } from 'node:http'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { promisify } from 'node:util'; import { derivePublicKey, hexToBytes } from '@pdsjs/core/crypto'; import { publicKeyToDidKey } from '@pdsjs/core/plc'; import { createServer } from '@pdsjs/node'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { GIT_REPO_COLLECTION } from '../src/index.js'; const execFileAsync = promisify(execFile); const ALICE_PORT = 2476; const BOB_PORT = 2477; const PLC_PORT = 2478; const ALICE_BASE = `http://127.0.0.1:${ALICE_PORT}`; const BOB_BASE = `http://127.0.0.1:${BOB_PORT}`; const PLC_URL = `http://127.0.0.1:${PLC_PORT}`; const PASSWORD = 'test-password'; const REPO = 'secret-repo'; // A private repository's space: the git type, keyed by the repository. const SPACE_TYPE = 'dev.pdsjs.git.space'; const SPACE_KEY = REPO; /** @param {number} length */ function randomBase32(length) { const chars = 'abcdefghijklmnopqrstuvwxyz234567'; return Array.from(randomBytes(length)) .map((b) => chars[b % 32]) .join(''); } const ALICE_DID = `did:plc:${randomBase32(24)}`; const BOB_DID = `did:plc:${randomBase32(24)}`; const SPACE_URI = `at://${ALICE_DID}/space/${SPACE_TYPE}/${SPACE_KEY}`; const REMOTE = `atproto://${ALICE_DID}/${REPO}`; /** @type {Array<{close: () => Promise}>} */ const servers = []; /** @type {import('node:http').Server|null} */ let plcServer = null; /** @type {Map} */ const didDocs = new Map(); /** @type {string} */ let workDir = ''; /** @type {string} */ let srcDir = ''; /** @type {Record} */ let baseEnv = {}; /** @type {string} */ let aliceJwt = ''; /** @type {string} */ let bobJwt = ''; /** * @param {string} cwd * @param {string[]} args * @param {Record} [extraEnv] * @returns {Promise} stdout */ async function runGit(cwd, args, extraEnv = {}) { const { stdout } = await execFileAsync('git', args, { cwd, env: { ...baseEnv, ...extraEnv }, encoding: 'utf8', }); return stdout; } /** * Boot a PDS, initialise its account, and publish the account's DID document * to the stub PLC. * @param {string} name * @param {number} port * @param {string} did * @returns {Promise} the account's access JWT */ async function bootPds(name, port, did) { const dir = join(workDir, name); mkdirSync(join(dir, 'blobs'), { recursive: true }); const server = await createServer({ port, dbPath: join(dir, 'pds.db'), blobsDir: join(dir, 'blobs'), jwtSecret: `test-secret-${name}`, hostname: `127.0.0.1:${port}`, password: PASSWORD, plcUrl: PLC_URL, spaces: true, }); await server.listen(); servers.push(server); const base = `http://127.0.0.1:${port}`; const privateKey = randomBytes(32).toString('hex'); const init = await fetch(`${base}/init?did=${did}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ did, privateKey, handle: `${name}.test`, password: PASSWORD, }), }); expect(init.ok).toBe(true); const { publicKey } = await derivePublicKey(hexToBytes(privateKey)); const multibase = publicKeyToDidKey(publicKey, 'p256').slice( 'did:key:'.length, ); didDocs.set(did, { '@context': ['https://www.w3.org/ns/did/v1'], id: did, alsoKnownAs: [`at://${name}.test`], verificationMethod: [ { id: `${did}#atproto`, type: 'Multikey', controller: did, publicKeyMultibase: multibase, }, ], service: [ { id: '#atproto_pds', type: 'AtprotoPersonalDataServer', serviceEndpoint: base, }, ], }); const res = await fetch(`${base}/xrpc/com.atproto.server.createSession`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ identifier: did, password: PASSWORD }), }); expect(res.ok).toBe(true); return /** @type {{accessJwt: string}} */ (await res.json()).accessJwt; } /** * @param {string} base * @param {string} nsid * @param {unknown} body * @param {string} jwt */ async function xrpcPost(base, nsid, body, jwt) { const res = await fetch(`${base}/xrpc/${nsid}`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${jwt}`, }, body: JSON.stringify(body), }); expect(res.ok, `${nsid}: ${res.status}`).toBe(true); return res.json(); } beforeAll(async () => { workDir = mkdtempSync(join(tmpdir(), 'pdsjs-git-space-')); plcServer = createHttpServer((req, res) => { const doc = didDocs.get(decodeURIComponent((req.url ?? '/').slice(1))); if (!doc) { res.writeHead(404).end('{}'); return; } res.writeHead(200, { 'content-type': 'application/json' }); res.end(JSON.stringify(doc)); }); await new Promise((resolve) => /** @type {import('node:http').Server} */ (plcServer).listen(PLC_PORT, () => resolve(undefined), ), ); aliceJwt = await bootPds('alice', ALICE_PORT, ALICE_DID); bobJwt = await bootPds('bob', BOB_PORT, BOB_DID); await xrpcPost( ALICE_BASE, 'com.atproto.simplespace.createSpace', { type: SPACE_TYPE, skey: SPACE_KEY }, aliceJwt, ); await xrpcPost( ALICE_BASE, 'com.atproto.simplespace.addMember', { space: SPACE_URI, did: BOB_DID }, aliceJwt, ); // PATH shim so git's atproto:// scheme resolves to our helper CLI const binDir = join(workDir, 'bin'); mkdirSync(binDir); const cliPath = join( dirname(fileURLToPath(import.meta.url)), '..', 'src', 'cli.js', ); const shim = join(binDir, 'git-remote-atproto'); writeFileSync(shim, `#!/bin/sh\nexec node ${cliPath} "$@"\n`); chmodSync(shim, 0o755); baseEnv = { .../** @type {Record} */ (process.env), PATH: `${binDir}:${process.env.PATH}`, ATPROTO_GIT_SERVICE: ALICE_BASE, ATPROTO_GIT_PLC_URL: PLC_URL, ATPROTO_GIT_CHUNK_SIZE: '1024', GIT_TERMINAL_PROMPT: '0', GIT_AUTHOR_NAME: 'Test', GIT_AUTHOR_EMAIL: 'test@example.com', GIT_COMMITTER_NAME: 'Test', GIT_COMMITTER_EMAIL: 'test@example.com', }; srcDir = join(workDir, 'src-repo'); mkdirSync(srcDir); await runGit(srcDir, ['init', '-b', 'main']); writeFileSync(join(srcDir, 'README.md'), '# private repo over a space\n'); writeFileSync(join(srcDir, 'data.bin'), randomBytes(4096)); await runGit(srcDir, ['add', '.']); await runGit(srcDir, ['commit', '-m', 'initial commit']); await runGit(srcDir, ['remote', 'add', 'origin', REMOTE]); }, 60_000); afterAll(async () => { for (const server of servers) await server.close(); if (plcServer) await new Promise((resolve) => plcServer?.close(resolve)); rmSync(workDir, { recursive: true, force: true }); }); const aliceEnv = () => ({ ATPROTO_GIT_IDENTIFIER: ALICE_DID, ATPROTO_GIT_PASSWORD: PASSWORD, }); const bobEnv = () => ({ ATPROTO_GIT_IDENTIFIER: BOB_DID, ATPROTO_GIT_PASSWORD: PASSWORD, ATPROTO_GIT_USER_SERVICE: BOB_BASE, }); describe('private git repository over a space', () => { it('the owner pushes into the space', async () => { await runGit(srcDir, ['push', 'origin', 'main'], aliceEnv()); const params = new URLSearchParams({ space: SPACE_URI, collection: GIT_REPO_COLLECTION, rkey: REPO, }); const res = await fetch( `${ALICE_BASE}/xrpc/com.atproto.space.getRecord?${params}`, { headers: { Authorization: `Bearer ${aliceJwt}` } }, ); expect(res.ok).toBe(true); const record = /** @type {{value: {name: string, refs: unknown[]}}} */ ( await res.json() ).value; expect(record.name).toBe(REPO); expect(record.refs).toHaveLength(1); }); it('the public blob endpoints refuse the private bundle chunks', async () => { const params = new URLSearchParams({ space: SPACE_URI, collection: GIT_REPO_COLLECTION, rkey: REPO, }); const record = /** @type {{value: {bundles: Array<{parts: Array<{ref: {$link: string}}>}>}}} */ ( await ( await fetch( `${ALICE_BASE}/xrpc/com.atproto.space.getRecord?${params}`, { headers: { Authorization: `Bearer ${aliceJwt}` } }, ) ).json() ).value; const chunkCid = record.bundles[0].parts[0].ref.$link; const blobParams = new URLSearchParams({ did: ALICE_DID, cid: chunkCid }); const publicBlob = await fetch( `${ALICE_BASE}/xrpc/com.atproto.sync.getBlob?${blobParams}`, ); expect(publicBlob.status).toBe(404); const listing = /** @type {{cids: string[]}} */ ( await ( await fetch(`${ALICE_BASE}/xrpc/com.atproto.sync.listBlobs?limit=1000`) ).json() ); expect(listing.cids).not.toContain(chunkCid); // The same chunk stays readable inside the space. const spaceBlobParams = new URLSearchParams({ space: SPACE_URI, cid: chunkCid, }); const spaceBlob = await fetch( `${ALICE_BASE}/xrpc/com.atproto.space.getBlob?${spaceBlobParams}`, { headers: { Authorization: `Bearer ${aliceJwt}` } }, ); expect(spaceBlob.ok).toBe(true); }); it('a publicly referenced blob is still served publicly', async () => { const upload = await fetch( `${ALICE_BASE}/xrpc/com.atproto.repo.uploadBlob`, { method: 'POST', headers: { 'Content-Type': 'application/octet-stream', Authorization: `Bearer ${aliceJwt}`, }, body: randomBytes(64), }, ); expect(upload.ok).toBe(true); const { blob } = /** @type {{blob: {ref: {$link: string}}}} */ ( await upload.json() ); const put = await fetch(`${ALICE_BASE}/xrpc/com.atproto.repo.putRecord`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${aliceJwt}`, }, body: JSON.stringify({ repo: ALICE_DID, collection: 'com.example.attachment', rkey: 'pub', record: { $type: 'com.example.attachment', file: blob }, }), }); expect(put.ok).toBe(true); const blobParams = new URLSearchParams({ did: ALICE_DID, cid: blob.ref.$link, }); const res = await fetch( `${ALICE_BASE}/xrpc/com.atproto.sync.getBlob?${blobParams}`, ); expect(res.ok).toBe(true); }); it('the record is not in the public repo', async () => { const params = new URLSearchParams({ repo: ALICE_DID, collection: GIT_REPO_COLLECTION, rkey: REPO, }); const res = await fetch( `${ALICE_BASE}/xrpc/com.atproto.repo.getRecord?${params}`, ); expect(res.status).toBe(404); }); it('reading the space record needs authentication', async () => { const params = new URLSearchParams({ space: SPACE_URI, collection: GIT_REPO_COLLECTION, rkey: REPO, }); const res = await fetch( `${ALICE_BASE}/xrpc/com.atproto.space.getRecord?${params}`, ); expect(res.status).toBe(401); }); it('the owner clones with their own session', async () => { const cloneDir = join(workDir, 'clone-alice'); await runGit(workDir, ['clone', REMOTE, cloneDir], aliceEnv()); const readme = await runGit(cloneDir, ['show', 'HEAD:README.md']); expect(readme).toContain('private repo over a space'); }); it('a member clones through the credential chain', async () => { const cloneDir = join(workDir, 'clone-bob'); await runGit(workDir, ['clone', REMOTE, cloneDir], bobEnv()); const readme = await runGit(cloneDir, ['show', 'HEAD:README.md']); expect(readme).toContain('private repo over a space'); }); it('a member pulls a later push', async () => { writeFileSync(join(srcDir, 'update.txt'), 'second commit\n'); await runGit(srcDir, ['add', '.']); await runGit(srcDir, ['commit', '-m', 'second commit']); await runGit(srcDir, ['push', 'origin', 'main'], aliceEnv()); const cloneDir = join(workDir, 'clone-bob'); await runGit(cloneDir, ['pull', 'origin', 'main'], bobEnv()); const update = await runGit(cloneDir, ['show', 'HEAD:update.txt']); expect(update).toContain('second commit'); }); it('a member pushes to their own record on their own PDS', async () => { const cloneDir = join(workDir, 'clone-bob'); const aliceParams = new URLSearchParams({ space: SPACE_URI, collection: GIT_REPO_COLLECTION, rkey: REPO, }); const canonicalBefore = await ( await fetch( `${ALICE_BASE}/xrpc/com.atproto.space.getRecord?${aliceParams}`, { headers: { Authorization: `Bearer ${aliceJwt}` } }, ) ).json(); writeFileSync(join(cloneDir, 'bob-note.txt'), 'from bob\n'); await runGit(cloneDir, ['add', '.']); await runGit(cloneDir, ['commit', '-m', 'bob note']); await runGit(cloneDir, ['push', 'origin', 'main'], bobEnv()); const bobRes = await fetch( `${BOB_BASE}/xrpc/com.atproto.space.getRecord?${aliceParams}`, { headers: { Authorization: `Bearer ${bobJwt}` } }, ); expect(bobRes.ok).toBe(true); const bobRecord = /** @type {{value: {refs: Array<{sha: string}>}}} */ ( await bobRes.json() ).value; expect(bobRecord.refs[0].sha).toBe( (await runGit(cloneDir, ['rev-parse', 'main'])).trim(), ); const canonicalAfter = await ( await fetch( `${ALICE_BASE}/xrpc/com.atproto.space.getRecord?${aliceParams}`, { headers: { Authorization: `Bearer ${aliceJwt}` } }, ) ).json(); expect(canonicalAfter.cid).toBe(canonicalBefore.cid); }); it("a fetch lists the member's branches under their handle", async () => { const cloneDir = join(workDir, 'clone-alice'); await runGit(cloneDir, ['fetch', 'origin'], aliceEnv()); const branches = await runGit(cloneDir, ['branch', '-r']); expect(branches).toContain('origin/bob.test/main'); const note = await runGit(cloneDir, [ 'show', 'origin/bob.test/main:bob-note.txt', ]); expect(note).toContain('from bob'); }); it("a fresh clone checks out the space owner's main", async () => { const cloneDir = join(workDir, 'clone-fresh'); await runGit(workDir, ['clone', REMOTE, cloneDir], aliceEnv()); expect((await runGit(cloneDir, ['branch', '--show-current'])).trim()).toBe( 'main', ); await expect( runGit(cloneDir, ['show', 'HEAD:bob-note.txt']), ).rejects.toThrow(); expect(await runGit(cloneDir, ['branch', '-r'])).toContain( 'origin/bob.test/main', ); }); it('the account page sees, browses, and manages the private repo', async () => { const signIn = await fetch(`${ALICE_BASE}/account/sign-in`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', Origin: ALICE_BASE, }, body: new URLSearchParams({ username: ALICE_DID, password: PASSWORD, }).toString(), redirect: 'manual', }); const cookie = (signIn.headers.get('set-cookie') ?? '').split(';')[0]; expect(cookie).toBeTruthy(); // The repositories listing carries the space repo with its space URI. const repos = /** @type {{repos: Array<{name: string, space: string|null, spaceOwned: boolean, cloneUrl: string}>}} */ ( await ( await fetch(`${ALICE_BASE}/xrpc/dev.pdsjs.git.listRepos`, { headers: { cookie }, }) ).json() ).repos; const spaceRepo = repos.find((r) => r.name === REPO); expect(spaceRepo?.space).toBe(SPACE_URI); expect(spaceRepo?.spaceOwned).toBe(true); // The clone URL names the authority by handle, and reads like a public // repository's: the helper finds the space from the name. expect(spaceRepo?.cloneUrl).toBe(`atproto://alice.test/${REPO}`); // File browsing reads the space record and blobs from storage. const treeParams = new URLSearchParams({ repo: REPO, space: SPACE_URI }); const tree = /** @type {{entries: Array<{name: string}>}} */ ( await ( await fetch(`${ALICE_BASE}/xrpc/dev.pdsjs.git.getTree?${treeParams}`, { headers: { cookie }, }) ).json() ); expect(tree.entries.map((e) => e.name)).toContain('README.md'); // Commit history reads straight from the bundle chain's commit objects. const logParams = new URLSearchParams({ repo: REPO, space: SPACE_URI }); const log = /** @type {{commits: Array<{message: string, author: string}>}} */ ( await ( await fetch( `${ALICE_BASE}/xrpc/dev.pdsjs.git.listCommits?${logParams}`, { headers: { cookie }, }, ) ).json() ); expect(log.commits.map((c) => c.message.trim()).sort()).toEqual([ 'initial commit', 'second commit', ]); expect(log.commits[0].author).toContain('Test'); // Member management round-trip. const memberParams = new URLSearchParams({ space: SPACE_URI }); const listMembers = async () => /** @type {{members: Array<{did: string}>}} */ ( await ( await fetch( `${ALICE_BASE}/account/api/spaces/members?${memberParams}`, { headers: { cookie } }, ) ).json() ).members.map((member) => member.did); expect(await listMembers()).toContain(BOB_DID); const extra = `did:plc:${randomBase32(24)}`; const add = await fetch(`${ALICE_BASE}/account/api/spaces/members/add`, { method: 'POST', headers: { cookie, 'Content-Type': 'application/json' }, body: JSON.stringify({ space: SPACE_URI, actor: extra }), }); expect(add.ok).toBe(true); expect(await listMembers()).toContain(extra); const remove = await fetch( `${ALICE_BASE}/account/api/spaces/members/remove`, { method: 'POST', headers: { cookie, 'Content-Type': 'application/json' }, body: JSON.stringify({ space: SPACE_URI, did: extra }), }, ); expect(remove.ok).toBe(true); expect(await listMembers()).not.toContain(extra); // Creating a space from the account page, the new-repository flow. const create = await fetch(`${ALICE_BASE}/account/api/spaces/create`, { method: 'POST', headers: { cookie, 'Content-Type': 'application/json' }, body: JSON.stringify({ type: 'com.example.second', skey: 'another' }), }); expect(create.ok).toBe(true); const { uri } = await create.json(); expect(uri).toBe(`at://${ALICE_DID}/space/com.example.second/another`); const dup = await fetch(`${ALICE_BASE}/account/api/spaces/create`, { method: 'POST', headers: { cookie, 'Content-Type': 'application/json' }, body: JSON.stringify({ type: 'com.example.second', skey: 'another' }), }); expect(dup.ok).toBe(false); }); it('a protected branch refuses force pushes and deletion, and takes fast-forwards', async () => { const signIn = await fetch(`${ALICE_BASE}/account/sign-in`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', Origin: ALICE_BASE, }, body: new URLSearchParams({ username: ALICE_DID, password: PASSWORD, }).toString(), redirect: 'manual', }); const cookie = (signIn.headers.get('set-cookie') ?? '').split(';')[0]; const settings = await fetch( `${ALICE_BASE}/xrpc/dev.pdsjs.git.updateSettings`, { method: 'POST', headers: { cookie, 'Content-Type': 'application/json' }, body: JSON.stringify({ name: REPO, space: SPACE_URI, description: 'guarded', protectedBranches: ['main'], }), }, ); expect(settings.ok).toBe(true); // The settings rewrite of the repo record must round-trip its blob refs: // a fresh clone still downloads and replays the chain. const checkDir = join(workDir, 'clone-guarded'); await runGit(workDir, ['clone', REMOTE, checkDir], aliceEnv()); // A fast-forward push is accepted. writeFileSync(join(checkDir, 'more.txt'), 'more\n'); await runGit(checkDir, ['add', '.']); await runGit(checkDir, ['commit', '-m', 'fast forward']); await runGit(checkDir, ['push', 'origin', 'main'], aliceEnv()); // A force push of rewritten history is refused by the server. await runGit(checkDir, ['commit', '--amend', '-m', 'rewritten']); await expect( runGit(checkDir, ['push', '--force', 'origin', 'main'], aliceEnv()), ).rejects.toThrow(/protected|fast-forward/); // So is deleting the branch. await expect( runGit(checkDir, ['push', 'origin', ':main'], aliceEnv()), ).rejects.toThrow(/protected/); // The listing reports both settings. const repos = /** @type {{repos: Array<{name: string, space: string|null, description: string|null, protectedBranches: string[]}>}} */ ( await ( await fetch(`${ALICE_BASE}/xrpc/dev.pdsjs.git.listRepos`, { headers: { cookie }, }) ).json() ).repos; const guarded = repos.find((r) => r.name === REPO && r.space === SPACE_URI); expect(guarded?.description).toBe('guarded'); expect(guarded?.protectedBranches).toEqual(['main']); }); it('a repository created empty from the account page takes its first push', async () => { const signIn = await fetch(`${ALICE_BASE}/account/sign-in`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', Origin: ALICE_BASE, }, body: new URLSearchParams({ username: ALICE_DID, password: PASSWORD, }).toString(), redirect: 'manual', }); const cookie = (signIn.headers.get('set-cookie') ?? '').split(';')[0]; // Private, with a new space, the way the dialog submits it. const createdPrivate = await fetch( `${ALICE_BASE}/xrpc/dev.pdsjs.git.createRepo`, { method: 'POST', headers: { cookie, 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'fresh-repo', private: true }), }, ); expect(createdPrivate.ok).toBe(true); const { space } = await createdPrivate.json(); expect(space).toBe( `at://${ALICE_DID}/space/dev.pdsjs.git.space/fresh-repo`, ); // Public too, and a duplicate name is refused. const createdPublic = await fetch( `${ALICE_BASE}/xrpc/dev.pdsjs.git.createRepo`, { method: 'POST', headers: { cookie, 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'fresh-public' }), }, ); expect(createdPublic.ok).toBe(true); const dup = await fetch(`${ALICE_BASE}/xrpc/dev.pdsjs.git.createRepo`, { method: 'POST', headers: { cookie, 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'fresh-public' }), }); expect(dup.ok).toBe(false); // Both list before any push, with nothing in them. const repos = /** @type {{repos: Array<{name: string, space: string|null, branches: number}>}} */ ( await ( await fetch(`${ALICE_BASE}/xrpc/dev.pdsjs.git.listRepos`, { headers: { cookie }, }) ).json() ).repos; const fresh = repos.find((r) => r.name === 'fresh-repo'); expect(fresh?.space).toBe(space); expect(fresh?.branches).toBe(0); expect( repos.find((r) => r.name === 'fresh-public' && r.space === null), ).toBeTruthy(); // The first push lands in the pre-created record through the CAS. const pushDir = join(workDir, 'fresh-src'); mkdirSync(pushDir); await runGit(pushDir, ['init', '-b', 'main']); writeFileSync(join(pushDir, 'hello.txt'), 'hello\n'); await runGit(pushDir, ['add', '.']); await runGit(pushDir, ['commit', '-m', 'first']); await runGit( pushDir, ['push', `atproto://${ALICE_DID}/fresh-repo`, 'main'], aliceEnv(), ); const after = /** @type {{repos: Array<{name: string, branches: number}>}} */ ( await ( await fetch(`${ALICE_BASE}/xrpc/dev.pdsjs.git.listRepos`, { headers: { cookie }, }) ).json() ).repos; expect(after.find((r) => r.name === 'fresh-repo')?.branches).toBe(1); // Protection holds on the public path too. await runGit( pushDir, ['push', `atproto://${ALICE_DID}/fresh-public`, 'main'], aliceEnv(), ); const protect = await fetch( `${ALICE_BASE}/xrpc/dev.pdsjs.git.updateSettings`, { method: 'POST', headers: { cookie, 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'fresh-public', protectedBranches: ['main'], }), }, ); expect(protect.ok).toBe(true); await runGit(pushDir, ['commit', '--amend', '-m', 'rewritten']); await expect( runGit( pushDir, ['push', '--force', `atproto://${ALICE_DID}/fresh-public`, 'main'], aliceEnv(), ), ).rejects.toThrow(/protected|fast-forward/); }); it('an anonymous clone is refused', async () => { const cloneDir = join(workDir, 'clone-anon'); await expect( runGit(workDir, ['clone', REMOTE, cloneDir], { ATPROTO_GIT_PASSWORD: '', HOME: join(workDir, 'no-home'), XDG_CONFIG_HOME: join(workDir, 'no-home'), }), ).rejects.toThrow(/no credentials|exit code/i); }); });