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)
})