OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git-v3 / test / access-token.test.js

189 lines · javascript ref:
// 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')
})