// Unit: personal access tokens (Phase 0/1 of classic-git-auth).
//
// Exercises the real mint → resolve → revoke → expiry → scope round-trip
// against an in-memory registry that mirrors the observable contract of
// hyper-identity's IdentityRegistry (the subset access-token.js touches:
// registerIdentity, linkIdentityToUser, getIdentity, getUserByIdentity,
// getUserWithIdentities, revokeIdentity). The real registry is exercised
// end-to-end in the HTTP push-gate tests.
import test from 'brittle'
import { mintToken, resolveToken, revokeToken, listTokens } from '../lib/auth/access-token.js'
import { canPushGit } from '../lib/auth/git-acl.js'
// Faithful in-memory stand-in for IdentityRegistry.
function memRegistry () {
const identities = new Map() // identityId -> record
const link = new Map() // identityId -> userId
const users = new Map() // userId -> user
const sshIndex = new Map() // fingerprint -> identityId
const revoked = new Set() // identityId (SEPARATE index — like the real registry)
return {
_addUser (userId, user) { users.set(userId, { userId, identities: [], ...user }) },
async registerIdentity (identity) {
identities.set(identity.identityId, { ...identity })
const fp = identity.credentials && identity.credentials.fingerprint
if (identity.method === 'ssh-key' && fp) sshIndex.set(fp, identity.identityId)
},
async linkIdentityToUser (identityId, userId, opts = {}) {
if (!opts.verified) throw new Error('linkIdentityToUser requires opts.verified=true')
link.set(identityId, userId)
const u = users.get(userId)
if (u) { u.identities = u.identities || []; if (!u.identities.includes(identityId)) u.identities.push(identityId) }
},
async getIdentity (id) { return identities.get(id) || null },
async getUserByIdentity (id) { const uid = link.get(id); return uid ? (users.get(uid) || null) : null },
async getUser (uid) { return users.get(uid) || null },
async getUserWithIdentities (uid) {
const user = users.get(uid)
if (!user) return null
const ids = (user.identities || []).map((i) => identities.get(i)).filter(Boolean)
return { user, identities: ids }
},
async getIdentityBySSHKey (fp) { const id = sshIndex.get(fp); return id ? (identities.get(id) || null) : null },
async isRevoked (id) { return revoked.has(id) },
// Faithful to the real registry: revoke writes a SEPARATE index; the
// identity RECORD from getIdentity is NOT mutated. (Modeling it as a record
// mutation is exactly what masked the live revocation bug.)
async revokeIdentity (id) { revoked.add(id) }
}
}
test('mintToken: returns a pgv3_ secret once and links to the user', async (t) => {
const reg = memRegistry()
reg._addUser('alice', { globalPermissions: ['write'] })
const minted = await mintToken(reg, 'alice', { name: 'laptop' })
t.ok(minted.secret.startsWith('pgv3_'), 'secret has pgv3_ prefix')
t.is(minted.scope, 'write', 'defaults to write scope')
t.ok(minted.identityId.startsWith('tok:'), 'identityId is tok:<hash>')
const resolved = await resolveToken(reg, minted.secret)
t.ok(resolved, 'token resolves')
t.is(resolved.user.userId, 'alice', 'resolves to the owning user')
t.is(resolved.scope, 'write', 'carries scope')
})
test('resolveToken: rejects malformed / unknown / wrong-method', async (t) => {
const reg = memRegistry()
reg._addUser('alice', { globalPermissions: ['write'] })
await mintToken(reg, 'alice')
t.is(await resolveToken(reg, null), null, 'null secret')
t.is(await resolveToken(reg, 'not-a-pgv3-token'), null, 'no prefix')
t.is(await resolveToken(reg, 'pgv3_deadbeef'), null, 'unknown secret')
})
test('resolveToken: revoked token stops resolving', async (t) => {
const reg = memRegistry()
reg._addUser('alice', { globalPermissions: ['write'] })
const minted = await mintToken(reg, 'alice')
t.ok(await resolveToken(reg, minted.secret), 'resolves before revoke')
await revokeToken(reg, minted.identityId)
t.is(await resolveToken(reg, minted.secret), null, 'denied after revoke')
})
test('resolveToken: expiry enforced (injectable clock)', async (t) => {
const reg = memRegistry()
reg._addUser('alice', { globalPermissions: ['write'] })
const minted = await mintToken(reg, 'alice', { expiresAt: 1000 })
t.ok(await resolveToken(reg, minted.secret, { now: 500 }), 'valid before expiry')
t.is(await resolveToken(reg, minted.secret, { now: 2000 }), null, 'expired after expiresAt')
})
test('mintToken: validates inputs', async (t) => {
const reg = memRegistry()
reg._addUser('alice', {})
await t.exception(() => mintToken(reg, 'alice', { scope: 'bogus' }), /invalid token scope/)
await t.exception(() => mintToken(reg, null), /requires a userId/)
})
test('listTokens: metadata only, excludes revoked, never the secret', async (t) => {
const reg = memRegistry()
reg._addUser('alice', { globalPermissions: ['write'] })
const a = await mintToken(reg, 'alice', { name: 'laptop', scope: 'write' })
const b = await mintToken(reg, 'alice', { name: 'ci', scope: 'read' })
let list = await listTokens(reg, 'alice')
t.is(list.length, 2, 'two active tokens')
const names = list.map((x) => x.name).sort()
t.alike(names, ['ci', 'laptop'], 'names listed')
t.absent(list.some((x) => 'secret' in x), 'no secret in listing')
await revokeToken(reg, b.identityId)
list = await listTokens(reg, 'alice')
t.is(list.length, 1, 'revoked token dropped from listing')
t.is(list[0].name, 'laptop', 'remaining token is the active one')
t.ok(a, 'minted a is referenced')
})
// ── canPushGit integration: token scope is a hard cap over user perms ──
test('canPushGit via token: write-token + write-user → push allowed', async (t) => {
const reg = memRegistry()
reg._addUser('alice', { globalPermissions: ['write'] })
const { secret } = await mintToken(reg, 'alice', { scope: 'write' })
t.ok(await canPushGit(reg, { transport: 'http', token: secret }), 'allowed')
})
test('canPushGit via token: read-scoped token cannot push even if user has write', async (t) => {
const reg = memRegistry()
reg._addUser('alice', { globalPermissions: ['write'] })
const { secret } = await mintToken(reg, 'alice', { scope: 'read' })
t.absent(await canPushGit(reg, { transport: 'http', token: secret }), 'scope caps below user perms')
})
test('canPushGit via token: write-token but read-only user → denied', async (t) => {
const reg = memRegistry()
reg._addUser('bob', { globalPermissions: ['read'] })
const { secret } = await mintToken(reg, 'bob', { scope: 'write' })
t.absent(await canPushGit(reg, { transport: 'http', token: secret }), 'user lacks write')
})
test('canPushGit via token: revoked / expired / unknown → denied', async (t) => {
const reg = memRegistry()
reg._addUser('alice', { globalPermissions: ['write'] })
const live = await mintToken(reg, 'alice', { scope: 'write' })
const expired = await mintToken(reg, 'alice', { scope: 'write', expiresAt: 1 })
await revokeToken(reg, live.identityId)
t.absent(await canPushGit(reg, { transport: 'http', token: live.secret }), 'revoked denied')
t.absent(await canPushGit(reg, { transport: 'http', token: expired.secret }), 'expired denied')
t.absent(await canPushGit(reg, { transport: 'http', token: 'pgv3_unknown' }), 'unknown denied')
})
// ── canPushGit via SSH fingerprint (resolver branch added in Phase 0) ──
test('canPushGit via sshFingerprint: linked key → user perms gate push', async (t) => {
const reg = memRegistry()
reg._addUser('alice', { globalPermissions: ['write'] })
await reg.registerIdentity({
identityId: 'ssh:SHA256:abc',
method: 'ssh-key',
credentials: { fingerprint: 'SHA256:abc', type: 'ed25519' }
})
await reg.linkIdentityToUser('ssh:SHA256:abc', 'alice', { verified: true })
t.ok(await canPushGit(reg, { transport: 'ssh', sshFingerprint: 'SHA256:abc' }),
'linked ssh key → write user → allowed')
t.absent(await canPushGit(reg, { transport: 'ssh', sshFingerprint: 'SHA256:unknown' }),
'unenrolled fingerprint → denied')
})
test('canPushGit via sshFingerprint: revoked ssh identity → denied', async (t) => {
const reg = memRegistry()
reg._addUser('alice', { globalPermissions: ['write'] })
await reg.registerIdentity({
identityId: 'ssh:SHA256:def',
method: 'ssh-key',
credentials: { fingerprint: 'SHA256:def', type: 'ed25519' }
})
await reg.linkIdentityToUser('ssh:SHA256:def', 'alice', { verified: true })
await reg.revokeIdentity('ssh:SHA256:def')
t.absent(await canPushGit(reg, { transport: 'ssh', sshFingerprint: 'SHA256:def' }),
'revoked ssh identity cannot push')
})