OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-identity / test / ssh-auto-import.test.js

157 lines · javascript ref:
import test from 'brittle'
import { mkdtempSync, mkdirSync, writeFileSync } from '#node:fs'
import { tmpdir } from '#node:os'
import { join } from '#node:path'
import process from '#node:process'

import { autoImportLocalSSHKeys } from '../lib/auth/ssh-auto-import.js'

// Real Ed25519 pubkey (no secret exposed)
const ED25519_A = 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPMbEhcffafHpuMVWZ0Q0iqQKg5vS7ALfRG0/XBT8fmP alice@host1'
const ED25519_B = 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINOyGaIPDcA7M6cFuLr/f4Wg4r7nnPwxbw2QNCvSqkJw alice@host2'

/**
 * Mock IdentityRegistry with just the methods autoImportLocalSSHKeys
 * needs. We avoid booting Corestore/Autobase/Hyperbee — the pure logic
 * (safety check, idempotency, fingerprint-based skip) is fully covered
 * with this shim.
 */
function makeMockRegistry (users = {}, identities = {}) {
  const sshIndex = {} // fingerprint → identityId
  const ops = [] // audit log for tests

  return {
    _state: { users, identities, sshIndex, ops },

    async getUser (userId) {
      return users[userId] || null
    },
    async getIdentity (identityId) {
      return identities[identityId] || null
    },
    async getIdentityBySSHKey (fingerprint) {
      const id = sshIndex[fingerprint]
      return id ? identities[id] : null
    },
    async registerIdentity (identity) {
      identities[identity.identityId] = identity
      if (identity.credentials && identity.credentials.fingerprint) {
        sshIndex[identity.credentials.fingerprint] = identity.identityId
      }
      ops.push({ op: 'register', identityId: identity.identityId })
    },
    async linkIdentityToUser (identityId, userId, opts) {
      if (!opts || !opts.verified) throw new Error('verified required')
      if (identities[identityId]) identities[identityId].userId = userId
      ops.push({ op: 'link', identityId, userId })
    },
    async addIdentityToUser (userId, identityId) {
      const u = users[userId]
      if (!u) return
      u.identities = u.identities || []
      if (!u.identities.includes(identityId)) u.identities.push(identityId)
      ops.push({ op: 'addToUser', userId, identityId })
    }
  }
}

function mkTmpHomeWithKeys (keys) {
  const dir = mkdtempSync(join(tmpdir(), 'hi-auto-import-'))
  mkdirSync(join(dir, '.ssh'))
  keys.forEach((k, i) => {
    writeFileSync(join(dir, '.ssh', `id_${i}.pub`), k + '\n')
  })
  return dir
}

function scopedHome (t, home) {
  const orig = process.env.HOME
  process.env.HOME = home
  t.teardown(() => { process.env.HOME = orig })
}

test('refuses when ownerUserId is missing', async (t) => {
  const reg = makeMockRegistry()
  await t.exception(() => autoImportLocalSSHKeys(reg, null), /ownerUserId/)
})

test('skips when owner does not exist in registry', async (t) => {
  scopedHome(t, mkTmpHomeWithKeys([ED25519_A]))
  const reg = makeMockRegistry()
  const result = await autoImportLocalSSHKeys(reg, 'nobody')
  t.is(result.imported, 0)
  t.ok(result.skippedReasons.noSuchUser, 'flagged')
  t.is(reg._state.ops.length, 0, 'no writes')
})

test('skips when owner has no existing non-ssh identity', async (t) => {
  scopedHome(t, mkTmpHomeWithKeys([ED25519_A]))
  // Owner exists but has only ssh-key identities — auto-import is
  // a bootstrap operation that needs to know a real user is there.
  const reg = makeMockRegistry(
    { alice: { userId: 'alice', identities: ['ssh:SHA256:x'] } },
    { 'ssh:SHA256:x': { identityId: 'ssh:SHA256:x', method: 'ssh-key' } }
  )
  const result = await autoImportLocalSSHKeys(reg, 'alice')
  t.is(result.imported, 0)
  t.ok(result.skippedReasons.noExistingIdentity)
})

test('imports new keys for an owner with an existing web-identity', async (t) => {
  scopedHome(t, mkTmpHomeWithKeys([ED25519_A, ED25519_B]))
  const reg = makeMockRegistry(
    { alice: { userId: 'alice', identities: ['web:abc'] } },
    { 'web:abc': { identityId: 'web:abc', method: 'web-identity' } }
  )

  const result = await autoImportLocalSSHKeys(reg, 'alice')
  t.is(result.scanned, 2)
  t.is(result.imported, 2)
  t.is(result.skipped, 0)

  // Both new identities are linked to alice
  const linked = reg._state.ops.filter(o => o.op === 'link' && o.userId === 'alice')
  t.is(linked.length, 2)
  const added = reg._state.ops.filter(o => o.op === 'addToUser' && o.userId === 'alice')
  t.is(added.length, 2)
})

test('idempotent: re-running skips existing fingerprints', async (t) => {
  scopedHome(t, mkTmpHomeWithKeys([ED25519_A]))
  const reg = makeMockRegistry(
    { alice: { userId: 'alice', identities: ['web:abc'] } },
    { 'web:abc': { identityId: 'web:abc', method: 'web-identity' } }
  )

  const a = await autoImportLocalSSHKeys(reg, 'alice')
  t.is(a.imported, 1, 'first run imports')

  const b = await autoImportLocalSSHKeys(reg, 'alice')
  t.is(b.imported, 0, 'second run imports nothing')
  t.is(b.skipped, 1, 'and skips the one existing key')
})

test('no-op when no keys on disk', async (t) => {
  const dir = mkdtempSync(join(tmpdir(), 'hi-empty-'))
  mkdirSync(join(dir, '.ssh'))
  scopedHome(t, dir)

  const reg = makeMockRegistry(
    { alice: { userId: 'alice', identities: ['web:abc'] } },
    { 'web:abc': { identityId: 'web:abc', method: 'web-identity' } }
  )
  const result = await autoImportLocalSSHKeys(reg, 'alice')
  t.is(result.scanned, 0)
  t.is(result.imported, 0)
})

test('requireExistingIdentity: false skips the safety check', async (t) => {
  scopedHome(t, mkTmpHomeWithKeys([ED25519_A]))
  const reg = makeMockRegistry(
    { alice: { userId: 'alice', identities: [] } }, // no identities
    {}
  )
  const result = await autoImportLocalSSHKeys(reg, 'alice', { requireExistingIdentity: false })
  t.is(result.imported, 1)
})