// Unit: managed authorized_keys writer (classic-git-auth Phase 2).
// Pure string transforms + an fs round-trip. No daemon, no real sshd.
import test from 'brittle'
import { promises as fs } from '#node:fs'
import os from '#node:os'
import { join } from '#node:path'
import {
renderKeyLine, buildManagedBlock, spliceManagedBlock,
readManagedFingerprints, syncAuthorizedKeys,
BEGIN_MARKER, END_MARKER
} from '../lib/auth/authorized-keys.js'
const KEY_A = { fingerprint: 'SHA256:AAAAfingerprintAAAA', publicKey: 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAAA alice@laptop' }
const KEY_B = { fingerprint: 'SHA256:BBBBfingerprintBBBB', publicKey: 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBBB bob@desk' }
const FOREIGN = 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOPS operator@home'
test('renderKeyLine: forced command + restrict + pubkey', (t) => {
const line = renderKeyLine(KEY_A, { wrapperPath: '/opt/pgv3-git-shell' })
t.is(line, 'command="/opt/pgv3-git-shell --fp=SHA256:AAAAfingerprintAAAA",restrict ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAAA alice@laptop')
t.ok(line.includes(',restrict '), 'restrict present')
t.ok(line.includes('--fp=' + KEY_A.fingerprint), 'fingerprint baked in')
})
test('renderKeyLine: bakes --sock and --repos; rejects unsafe paths', (t) => {
const line = renderKeyLine(KEY_A, {
wrapperPath: '/opt/pgv3-git-shell',
sock: '/home/fleeky/pgv3-storage/svc.sock',
reposDir: '/home/fleeky/git-repos'
})
t.ok(line.includes('--sock=/home/fleeky/pgv3-storage/svc.sock'), 'socket baked in')
t.ok(line.includes('--repos=/home/fleeky/git-repos'), 'repos dir baked in')
t.exception(() => renderKeyLine(KEY_A, { sock: '/has space/svc.sock' }), /invalid socket path/)
})
test('renderKeyLine: rejects injection / bad inputs', (t) => {
t.exception(() => renderKeyLine({ fingerprint: 'not-a-fp', publicKey: KEY_A.publicKey }), /invalid SSH fingerprint/)
t.exception(() => renderKeyLine({ fingerprint: KEY_A.fingerprint, publicKey: 'ssh-ed25519 AAA\ncommand="evil"' }), /single line/)
t.exception(() => renderKeyLine({ fingerprint: KEY_A.fingerprint, publicKey: 'totally bogus' }), /unrecognized public key/)
})
test('buildManagedBlock: markers wrap the lines; empty set → empty string', (t) => {
const block = buildManagedBlock([KEY_A, KEY_B])
t.ok(block.startsWith(BEGIN_MARKER), 'begins with marker')
t.ok(block.endsWith(END_MARKER), 'ends with marker')
t.is((block.match(/--fp=/g) || []).length, 2, 'two key lines')
t.is(buildManagedBlock([]), '', 'empty set → empty block')
})
test('spliceManagedBlock: appends to foreign content, preserving it', (t) => {
const block = buildManagedBlock([KEY_A])
const out = spliceManagedBlock(FOREIGN + '\n', block)
t.ok(out.includes(FOREIGN), 'foreign key preserved')
t.ok(out.includes(BEGIN_MARKER) && out.includes('alice@laptop'), 'managed block appended')
t.ok(out.endsWith('\n'), 'trailing newline')
})
test('spliceManagedBlock: replaces an existing block, preserving foreign before/after', (t) => {
const first = spliceManagedBlock('# top\n' + FOREIGN + '\n', buildManagedBlock([KEY_A]))
const withTail = first + '\n# tail-comment\n'
const replaced = spliceManagedBlock(withTail, buildManagedBlock([KEY_B]))
t.ok(replaced.includes(FOREIGN), 'foreign-before preserved')
t.ok(replaced.includes('# tail-comment'), 'foreign-after preserved')
t.ok(replaced.includes('bob@desk') && !replaced.includes('alice@laptop'), 'block content swapped')
t.is((replaced.match(new RegExp(BEGIN_MARKER, 'g')) || []).length, 1, 'exactly one managed block')
})
test('spliceManagedBlock: empty block removes the managed region, keeps foreign', (t) => {
const withBlock = spliceManagedBlock(FOREIGN + '\n', buildManagedBlock([KEY_A]))
const removed = spliceManagedBlock(withBlock, '')
t.ok(removed.includes(FOREIGN), 'foreign key kept')
t.absent(removed.includes(BEGIN_MARKER), 'managed block gone')
})
test('spliceManagedBlock: idempotent for the same key set', (t) => {
const block = buildManagedBlock([KEY_A, KEY_B])
const once = spliceManagedBlock(FOREIGN + '\n', block)
const twice = spliceManagedBlock(once, block)
t.is(once, twice, 'splicing the same block twice is stable')
})
test('readManagedFingerprints: extracts enrolled fingerprints', (t) => {
const content = spliceManagedBlock('', buildManagedBlock([KEY_A, KEY_B]))
t.alike(readManagedFingerprints(content), [KEY_A.fingerprint, KEY_B.fingerprint])
t.alike(readManagedFingerprints('no markers here'), [], 'none when no block')
})
test('syncAuthorizedKeys: writes 0600, regenerates, removes, preserves foreign', async (t) => {
const dir = await fs.mkdtemp(join(os.tmpdir(), 'pgv3-ak-'))
const path = join(dir, 'authorized_keys')
// seed with a foreign operator key
await fs.writeFile(path, FOREIGN + '\n', { mode: 0o600 })
await syncAuthorizedKeys(path, [KEY_A, KEY_B], { wrapperPath: '/opt/pgv3-git-shell' })
let content = await fs.readFile(path, 'utf8')
t.ok(content.includes(FOREIGN), 'foreign key preserved')
t.alike(readManagedFingerprints(content), [KEY_A.fingerprint, KEY_B.fingerprint], 'both keys enrolled')
const mode = (await fs.stat(path)).mode & 0o777
t.is(mode, 0o600, 'file is 0600')
// revoke one → regenerate from the reduced set
await syncAuthorizedKeys(path, [KEY_A], { wrapperPath: '/opt/pgv3-git-shell' })
content = await fs.readFile(path, 'utf8')
t.alike(readManagedFingerprints(content), [KEY_A.fingerprint], 'one key remains')
// empty set → block removed, foreign still there
await syncAuthorizedKeys(path, [], {})
content = await fs.readFile(path, 'utf8')
t.absent(content.includes(BEGIN_MARKER), 'managed block removed')
t.ok(content.includes(FOREIGN), 'foreign key still preserved')
await fs.rm(dir, { recursive: true, force: true })
})