OverviewFilesCommitsIssuesDocsReleasesPeers

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

161 lines · javascript ref:
// Unit: access-token RPC handlers (create / list / revoke) including the
// self-service auth model and the revoke ownership guard.

import test from 'brittle'
import { accessTokenHandlers } from '../lib/handlers/access-tokens.js'
import { canPushGit } from '../lib/auth/git-acl.js'

// In-memory IdentityRegistry stand-in (same observable contract the handlers
// and access-token.js rely on).
function memRegistry () {
  const identities = new Map()
  const link = new Map()
  const users = new Map()
  const revoked = new Set()
  return {
    _addUser (userId, user) { users.set(userId, { userId, identities: [], ...user }) },
    // link a web-identity pubkey → user so ctx.identity.publicKey resolves
    _linkWeb (pubkey, userId) {
      identities.set(pubkey, { identityId: pubkey, method: 'web-identity' })
      link.set(pubkey, userId)
      const u = users.get(userId); if (u) u.identities.push(pubkey)
    },
    async registerIdentity (identity) { identities.set(identity.identityId, { ...identity }) },
    async linkIdentityToUser (identityId, userId, opts = {}) {
      if (!opts.verified) throw new Error('requires verified')
      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 listUsers () { return Array.from(users.values()) },
    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 isRevoked (id) { return revoked.has(id) },
    async revokeIdentity (id) { revoked.add(id) } // separate index, record untouched (matches real registry)
  }
}

function handlers (reg, identity) {
  const svc = { identityRegistry: reg, identity: identity || null }
  const map = {}
  for (const { name, fn } of accessTokenHandlers(svc)) map[name] = fn
  return map
}

const httpCtx = (pubkey) => ({ transport: 'http', identity: { publicKey: pubkey } })
const udsCtx = () => ({ transport: 'uds' })

test('create-access-token: HTTP caller mints for self, secret returned once', async (t) => {
  const reg = memRegistry()
  reg._addUser('alice', { globalPermissions: ['write'] })
  reg._linkWeb('alice-pub', 'alice')
  const h = handlers(reg)

  const out = await h['create-access-token'](httpCtx('alice-pub'), { name: 'laptop' })
  t.ok(out.secret.startsWith('pgv3_'), 'returns a pgv3_ secret')
  t.is(out.scope, 'write', 'default write scope')
  t.ok(out.expiresAt > Date.now(), 'default expiry is in the future (90d)')

  // and it actually authorizes a push
  t.ok(await canPushGit(reg, { transport: 'http', token: out.secret }), 'minted token can push')
})

test('create-access-token: ttlDays:0 → no expiry', async (t) => {
  const reg = memRegistry()
  reg._addUser('alice', { globalPermissions: ['write'] })
  reg._linkWeb('alice-pub', 'alice')
  const h = handlers(reg)
  const out = await h['create-access-token'](httpCtx('alice-pub'), { ttlDays: 0 })
  t.is(out.expiresAt, null, 'explicit no-expiry')
})

test('create-access-token: UDS mints for explicit userId, else the owner', async (t) => {
  const reg = memRegistry()
  reg._addUser('alice', { globalPermissions: ['write'] })
  reg._addUser('owner', { globalPermissions: ['admin'] })
  const h = handlers(reg, null)
  const explicit = await h['create-access-token'](udsCtx(), { userId: 'alice' })
  t.ok(explicit.secret.startsWith('pgv3_'), 'explicit userId honoured')
  const def = await h['create-access-token'](udsCtx(), {})
  t.ok(def.secret.startsWith('pgv3_'), 'no userId → defaults to owner')
})

test('create-access-token: UDS resolves owner via hyperident root identity', async (t) => {
  const reg = memRegistry()
  reg._addUser('owner', { globalPermissions: ['admin'] })
  reg._linkWeb('root-pub', 'owner') // hyperident root linked to the owner user
  const h = handlers(reg, { rootPubkeyHex: 'root-pub' })
  const out = await h['create-access-token'](udsCtx(), {})
  t.ok(out.secret.startsWith('pgv3_'), 'minted for the hyperident owner')
  const { tokens } = await h['list-access-tokens'](udsCtx(), {})
  t.is(tokens.length, 1, 'token belongs to the owner')
})

test('create-access-token: UDS with no owner yet → clear error', async (t) => {
  const reg = memRegistry()
  reg._addUser('reader', { globalPermissions: ['read'] }) // no admin exists
  const h = handlers(reg, null)
  await t.exception(() => h['create-access-token'](udsCtx(), {}), /no owner user yet/)
})

test('list-access-tokens: metadata only, no secret', async (t) => {
  const reg = memRegistry()
  reg._addUser('alice', { globalPermissions: ['write'] })
  reg._linkWeb('alice-pub', 'alice')
  const h = handlers(reg)
  await h['create-access-token'](httpCtx('alice-pub'), { name: 'laptop' })

  const { tokens } = await h['list-access-tokens'](httpCtx('alice-pub'), {})
  t.is(tokens.length, 1, 'one token listed')
  t.is(tokens[0].name, 'laptop', 'name surfaced')
  t.absent('secret' in tokens[0], 'no secret in listing')
})

test('revoke-access-token: owner can revoke; push then denied', async (t) => {
  const reg = memRegistry()
  reg._addUser('alice', { globalPermissions: ['write'] })
  reg._linkWeb('alice-pub', 'alice')
  const h = handlers(reg)
  const minted = await h['create-access-token'](httpCtx('alice-pub'), {})

  const res = await h['revoke-access-token'](httpCtx('alice-pub'), { identityId: minted.identityId })
  t.ok(res.revoked, 'revoked')
  t.absent(await canPushGit(reg, { transport: 'http', token: minted.secret }), 'revoked token cannot push')
  const { tokens } = await h['list-access-tokens'](httpCtx('alice-pub'), {})
  t.is(tokens.length, 0, 'gone from listing')
})

test('revoke-access-token: cannot revoke another user\'s token', async (t) => {
  const reg = memRegistry()
  reg._addUser('alice', { globalPermissions: ['write'] })
  reg._addUser('bob', { globalPermissions: ['write'] })
  reg._linkWeb('alice-pub', 'alice')
  reg._linkWeb('bob-pub', 'bob')
  const h = handlers(reg)
  const aliceTok = await h['create-access-token'](httpCtx('alice-pub'), {})

  await t.exception(
    () => h['revoke-access-token'](httpCtx('bob-pub'), { identityId: aliceTok.identityId }),
    /do not own/
  )
  // alice's token still works
  t.ok(await canPushGit(reg, { transport: 'http', token: aliceTok.secret }), 'untouched by bob')
})

test('revoke-access-token: rejects non-token identityId', async (t) => {
  const reg = memRegistry()
  reg._addUser('alice', { globalPermissions: ['write'] })
  reg._linkWeb('alice-pub', 'alice')
  const h = handlers(reg)
  await t.exception(
    () => h['revoke-access-token'](httpCtx('alice-pub'), { identityId: 'ssh:SHA256:x' }),
    /requires \{ identityId/
  )
})