import test from 'brittle'
import fs from 'bare-fs'
import path from 'bare-path'
import os from 'bare-os'
import Corestore from 'corestore'
import Hyperbee from 'hyperbee'
import { apply } from '../../lib/auth/identity-apply.js'
import { encodeOp } from '../../lib/auth/identity-operations.js'
const tmpDir = path.join(os.tmpdir(), 'pear-git-test-id-apply-' + Date.now())
test('setup: create temp dir', async (t) => {
await fs.promises.mkdir(tmpDir, { recursive: true })
t.pass('temp dir created')
})
async function createView (name) {
const store = new Corestore(path.join(tmpDir, name))
const core = store.get({ name: 'test-view', valueEncoding: 'json' })
const view = new Hyperbee(core, { keyEncoding: 'utf-8', valueEncoding: 'json' })
await view.ready()
return { store, view }
}
function node (op) {
return { value: encodeOp(op) }
}
// ========================================
// create-user
// ========================================
test('create-user: creates user in view', async (t) => {
const { store, view } = await createView('cu-basic')
await apply([node({
type: 'create-user',
userId: 'u1',
name: 'Alice',
email: 'alice@example.com',
timestamp: 1000
})], view, null)
const entry = await view.get('user:u1')
t.ok(entry, 'user exists')
t.is(entry.value.userId, 'u1')
t.is(entry.value.name, 'Alice')
t.is(entry.value.email, 'alice@example.com')
t.alike(entry.value.identities, [])
t.is(entry.value.createdAt, 1000)
await view.close()
await store.close()
})
test('create-user: idempotent (second call is no-op)', async (t) => {
const { store, view } = await createView('cu-idempotent')
const op = node({
type: 'create-user',
userId: 'u1',
name: 'Alice',
timestamp: 1000
})
await apply([op], view, null)
// Second call with different name should be ignored
await apply([node({
type: 'create-user',
userId: 'u1',
name: 'Bob',
timestamp: 2000
})], view, null)
const entry = await view.get('user:u1')
t.is(entry.value.name, 'Alice', 'original name preserved')
t.is(entry.value.createdAt, 1000, 'original timestamp preserved')
await view.close()
await store.close()
})
// ========================================
// create-identity with reverse lookups
// ========================================
test('create-identity: ssh-key with reverse lookup', async (t) => {
const { store, view } = await createView('ci-ssh')
await apply([node({
type: 'create-identity',
identityId: 'id-ssh1',
method: 'ssh-key',
credentials: { fingerprint: 'fp-abc123' },
timestamp: 1000
})], view, null)
const identity = await view.get('identity:id-ssh1')
t.ok(identity, 'identity created')
t.is(identity.value.method, 'ssh-key')
const lookup = await view.get('sshkey:fp-abc123')
t.ok(lookup, 'reverse lookup exists')
t.is(lookup.value.identityId, 'id-ssh1')
await view.close()
await store.close()
})
test('create-identity: noise-key with reverse lookup', async (t) => {
const { store, view } = await createView('ci-noise')
await apply([node({
type: 'create-identity',
identityId: 'id-noise1',
method: 'noise-key',
credentials: { publicKey: 'deadbeef' },
timestamp: 1000
})], view, null)
const lookup = await view.get('noisekey:deadbeef')
t.ok(lookup, 'noise-key reverse lookup exists')
t.is(lookup.value.identityId, 'id-noise1')
await view.close()
await store.close()
})
test('create-identity: web-identity with reverse lookup', async (t) => {
const { store, view } = await createView('ci-web')
await apply([node({
type: 'create-identity',
identityId: 'id-web1',
method: 'web-identity',
credentials: { publicKey: 'webpubkey123' },
timestamp: 1000
})], view, null)
const lookup = await view.get('pubkey:webpubkey123')
t.ok(lookup, 'pubkey reverse lookup exists')
t.is(lookup.value.identityId, 'id-web1')
await view.close()
await store.close()
})
test('create-identity: adds to user identities if userId provided', async (t) => {
const { store, view } = await createView('ci-with-user')
await apply([
node({ type: 'create-user', userId: 'u1', name: 'Alice', timestamp: 1000 }),
node({
type: 'create-identity',
identityId: 'id1',
method: 'web-identity',
userId: 'u1',
credentials: { publicKey: 'pk1' },
timestamp: 1001
})
], view, null)
const user = await view.get('user:u1')
t.alike(user.value.identities, ['id1'], 'identity added to user')
await view.close()
await store.close()
})
test('create-identity: idempotent', async (t) => {
const { store, view } = await createView('ci-idempotent')
const op = node({
type: 'create-identity',
identityId: 'id1',
method: 'ssh-key',
credentials: { fingerprint: 'fp1' },
timestamp: 1000
})
await apply([op], view, null)
await apply([op], view, null)
const identity = await view.get('identity:id1')
t.is(identity.value.createdAt, 1000, 'original preserved')
await view.close()
await store.close()
})
// ========================================
// link-identity
// ========================================
test('link-identity: updates both user and identity', async (t) => {
const { store, view } = await createView('link-basic')
await apply([
node({ type: 'create-user', userId: 'u1', name: 'Alice', timestamp: 1000 }),
node({
type: 'create-identity',
identityId: 'id1',
method: 'web-identity',
credentials: { publicKey: 'pk1' },
timestamp: 1001
})
], view, null)
await apply([node({
type: 'link-identity',
identityId: 'id1',
userId: 'u1',
timestamp: 2000
})], view, null)
const identity = await view.get('identity:id1')
t.is(identity.value.userId, 'u1', 'identity linked to user')
const user = await view.get('user:u1')
t.ok(user.value.identities.includes('id1'), 'user has identity')
await view.close()
await store.close()
})
test('link-identity: skips if identity not found', async (t) => {
const { store, view } = await createView('link-no-id')
await apply([
node({ type: 'create-user', userId: 'u1', name: 'Alice', timestamp: 1000 })
], view, null)
// Should not throw
await apply([node({
type: 'link-identity',
identityId: 'nonexistent',
userId: 'u1',
timestamp: 2000
})], view, null)
t.pass('no throw on missing identity')
await view.close()
await store.close()
})
test('link-identity: skips if user not found', async (t) => {
const { store, view } = await createView('link-no-user')
await apply([node({
type: 'create-identity',
identityId: 'id1',
method: 'web-identity',
credentials: { publicKey: 'pk1' },
timestamp: 1000
})], view, null)
await apply([node({
type: 'link-identity',
identityId: 'id1',
userId: 'nonexistent',
timestamp: 2000
})], view, null)
const identity = await view.get('identity:id1')
t.is(identity.value.userId, null, 'identity not linked')
await view.close()
await store.close()
})
// ========================================
// unlink-identity
// ========================================
test('unlink-identity: clears link', async (t) => {
const { store, view } = await createView('unlink-basic')
await apply([
node({ type: 'create-user', userId: 'u1', name: 'Alice', timestamp: 1000 }),
node({
type: 'create-identity',
identityId: 'id1',
method: 'web-identity',
userId: 'u1',
credentials: { publicKey: 'pk1' },
timestamp: 1001
})
], view, null)
await apply([node({
type: 'unlink-identity',
identityId: 'id1',
timestamp: 2000
})], view, null)
const identity = await view.get('identity:id1')
t.is(identity.value.userId, null, 'identity unlinked')
const user = await view.get('user:u1')
t.alike(user.value.identities, [], 'user identities empty')
await view.close()
await store.close()
})
test('unlink-identity: skips if identity not found', async (t) => {
const { store, view } = await createView('unlink-missing')
await apply([node({
type: 'unlink-identity',
identityId: 'nonexistent',
timestamp: 2000
})], view, null)
t.pass('no throw on missing identity')
await view.close()
await store.close()
})
// ========================================
// revoke / restore roundtrip
// ========================================
test('revoke-identity: marks identity as revoked', async (t) => {
const { store, view } = await createView('revoke-basic')
await apply([
node({
type: 'create-identity',
identityId: 'id1',
method: 'ssh-key',
credentials: { fingerprint: 'fp1' },
timestamp: 1000
})
], view, null)
await apply([node({
type: 'revoke-identity',
identityId: 'id1',
revokedBy: 'admin',
reason: 'compromised',
timestamp: 2000
})], view, null)
const identity = await view.get('identity:id1')
t.is(identity.value.revoked, true)
t.is(identity.value.revokedBy, 'admin')
t.is(identity.value.revocationReason, 'compromised')
const revoked = await view.get('revoked:id1')
t.ok(revoked, 'revocation entry exists')
t.is(revoked.value.identityId, 'id1')
await view.close()
await store.close()
})
test('revoke-identity: idempotent (skip if already revoked)', async (t) => {
const { store, view } = await createView('revoke-idempotent')
await apply([
node({
type: 'create-identity',
identityId: 'id1',
method: 'ssh-key',
credentials: { fingerprint: 'fp1' },
timestamp: 1000
}),
node({
type: 'revoke-identity',
identityId: 'id1',
revokedBy: 'admin',
reason: 'first',
timestamp: 2000
})
], view, null)
// Second revocation should be no-op
await apply([node({
type: 'revoke-identity',
identityId: 'id1',
revokedBy: 'other',
reason: 'second',
timestamp: 3000
})], view, null)
const identity = await view.get('identity:id1')
t.is(identity.value.revocationReason, 'first', 'original reason preserved')
await view.close()
await store.close()
})
test('restore-identity: removes revocation', async (t) => {
const { store, view } = await createView('restore-basic')
await apply([
node({
type: 'create-identity',
identityId: 'id1',
method: 'ssh-key',
credentials: { fingerprint: 'fp1' },
timestamp: 1000
}),
node({
type: 'revoke-identity',
identityId: 'id1',
revokedBy: 'admin',
reason: 'test',
timestamp: 2000
})
], view, null)
await apply([node({
type: 'restore-identity',
identityId: 'id1',
timestamp: 3000
})], view, null)
const identity = await view.get('identity:id1')
t.is(identity.value.revoked, undefined, 'revoked flag removed')
t.is(identity.value.revokedAt, undefined, 'revokedAt removed')
t.is(identity.value.revokedBy, undefined, 'revokedBy removed')
t.is(identity.value.restoredAt, 3000, 'restoredAt set')
const revoked = await view.get('revoked:id1')
t.absent(revoked, 'revocation entry deleted')
await view.close()
await store.close()
})
test('restore-identity: skips if not revoked', async (t) => {
const { store, view } = await createView('restore-not-revoked')
await apply([
node({
type: 'create-identity',
identityId: 'id1',
method: 'ssh-key',
credentials: { fingerprint: 'fp1' },
timestamp: 1000
})
], view, null)
// Should not throw
await apply([node({
type: 'restore-identity',
identityId: 'id1',
timestamp: 2000
})], view, null)
const identity = await view.get('identity:id1')
t.is(identity.value.restoredAt, undefined, 'restoredAt not set')
await view.close()
await store.close()
})
// ========================================
// update-user
// ========================================
test('update-user: updates provided fields', async (t) => {
const { store, view } = await createView('update-user')
await apply([
node({ type: 'create-user', userId: 'u1', name: 'Alice', timestamp: 1000 })
], view, null)
await apply([node({
type: 'update-user',
userId: 'u1',
name: 'Alice Updated',
email: 'alice@new.com',
timestamp: 2000
})], view, null)
const user = await view.get('user:u1')
t.is(user.value.name, 'Alice Updated')
t.is(user.value.email, 'alice@new.com')
t.is(user.value.updatedAt, 2000)
await view.close()
await store.close()
})
test('update-user: skips if user not found', async (t) => {
const { store, view } = await createView('update-user-missing')
await apply([node({
type: 'update-user',
userId: 'nonexistent',
name: 'Ghost',
timestamp: 2000
})], view, null)
t.pass('no throw on missing user')
await view.close()
await store.close()
})
// ========================================
// upgrade-permissions
// ========================================
test('upgrade-permissions: merges permissions (Set union)', async (t) => {
const { store, view } = await createView('upgrade-perms')
await apply([
node({ type: 'create-user', userId: 'u1', name: 'Alice', timestamp: 1000 })
], view, null)
await apply([node({
type: 'upgrade-permissions',
userId: 'u1',
repoName: 'myrepo',
permissions: ['read', 'write'],
timestamp: 2000
})], view, null)
// Upgrade again with overlapping + new
await apply([node({
type: 'upgrade-permissions',
userId: 'u1',
repoName: 'myrepo',
permissions: ['write', 'admin'],
timestamp: 3000
})], view, null)
const user = await view.get('user:u1')
const repo = user.value.repos.find(r => r.name === 'myrepo')
t.ok(repo, 'repo entry exists')
t.ok(repo.permissions.includes('read'), 'has read')
t.ok(repo.permissions.includes('write'), 'has write')
t.ok(repo.permissions.includes('admin'), 'has admin')
t.is(repo.permissions.length, 3, 'no duplicates')
await view.close()
await store.close()
})
test('upgrade-permissions: skips if user not found', async (t) => {
const { store, view } = await createView('upgrade-missing')
await apply([node({
type: 'upgrade-permissions',
userId: 'nonexistent',
repoName: 'repo',
permissions: ['write'],
timestamp: 2000
})], view, null)
t.pass('no throw on missing user')
await view.close()
await store.close()
})
// ========================================
// merge-accounts
// ========================================
test('merge-accounts: transfers identities and permissions', async (t) => {
const { store, view } = await createView('merge-basic')
await apply([
node({ type: 'create-user', userId: 'primary', name: 'Primary', globalPermissions: ['read'], timestamp: 1000 }),
node({ type: 'create-user', userId: 'secondary', name: 'Secondary', globalPermissions: ['read', 'write'], repos: [{ name: 'repo1', permissions: ['read', 'write'] }], timestamp: 1001 }),
node({
type: 'create-identity',
identityId: 'id-sec',
method: 'web-identity',
userId: 'secondary',
credentials: { publicKey: 'pk-sec' },
timestamp: 1002
})
], view, null)
await apply([node({
type: 'merge-accounts',
primaryUserId: 'primary',
secondaryUserId: 'secondary',
timestamp: 3000
})], view, null)
// Primary should have the identity
const primary = await view.get('user:primary')
t.ok(primary.value.identities.includes('id-sec'), 'identity transferred')
t.ok(primary.value.globalPermissions.includes('write'), 'global perms merged')
const repoEntry = primary.value.repos.find(r => r.name === 'repo1')
t.ok(repoEntry, 'repo permissions transferred')
t.ok(repoEntry.permissions.includes('write'), 'write perm present')
// Secondary should be marked as merged
const secondary = await view.get('user:secondary')
t.is(secondary.value.mergedInto, 'primary')
t.alike(secondary.value.identities, [])
t.alike(secondary.value.repos, [])
// Identity should point to primary
const identity = await view.get('identity:id-sec')
t.is(identity.value.userId, 'primary')
await view.close()
await store.close()
})
test('merge-accounts: skips revoked identities', async (t) => {
const { store, view } = await createView('merge-revoked')
await apply([
node({ type: 'create-user', userId: 'primary', name: 'Primary', timestamp: 1000 }),
node({ type: 'create-user', userId: 'secondary', name: 'Secondary', timestamp: 1001 }),
node({
type: 'create-identity',
identityId: 'id-good',
method: 'web-identity',
userId: 'secondary',
credentials: { publicKey: 'pk-good' },
timestamp: 1002
}),
node({
type: 'create-identity',
identityId: 'id-bad',
method: 'ssh-key',
userId: 'secondary',
credentials: { fingerprint: 'fp-bad' },
timestamp: 1003
}),
node({
type: 'revoke-identity',
identityId: 'id-bad',
reason: 'compromised',
timestamp: 1004
})
], view, null)
await apply([node({
type: 'merge-accounts',
primaryUserId: 'primary',
secondaryUserId: 'secondary',
timestamp: 3000
})], view, null)
const primary = await view.get('user:primary')
t.ok(primary.value.identities.includes('id-good'), 'good identity transferred')
t.absent(primary.value.identities.includes('id-bad'), 'revoked identity not transferred')
await view.close()
await store.close()
})
test('merge-accounts: skips if already merged', async (t) => {
const { store, view } = await createView('merge-idempotent')
await apply([
node({ type: 'create-user', userId: 'primary', name: 'Primary', timestamp: 1000 }),
node({ type: 'create-user', userId: 'secondary', name: 'Secondary', timestamp: 1001 })
], view, null)
await apply([node({
type: 'merge-accounts',
primaryUserId: 'primary',
secondaryUserId: 'secondary',
timestamp: 2000
})], view, null)
// Second merge should be no-op
await apply([node({
type: 'merge-accounts',
primaryUserId: 'primary',
secondaryUserId: 'secondary',
timestamp: 3000
})], view, null)
const secondary = await view.get('user:secondary')
t.is(secondary.value.mergedAt, 2000, 'original merge timestamp preserved')
await view.close()
await store.close()
})
test('merge-accounts: skips if either user not found', async (t) => {
const { store, view } = await createView('merge-missing')
await apply([
node({ type: 'create-user', userId: 'primary', name: 'Primary', timestamp: 1000 })
], view, null)
await apply([node({
type: 'merge-accounts',
primaryUserId: 'primary',
secondaryUserId: 'nonexistent',
timestamp: 2000
})], view, null)
t.pass('no throw when secondary missing')
await apply([node({
type: 'merge-accounts',
primaryUserId: 'nonexistent',
secondaryUserId: 'primary',
timestamp: 2000
})], view, null)
t.pass('no throw when primary missing')
await view.close()
await store.close()
})
// ========================================
// Missing entities handled gracefully
// ========================================
test('all ops: missing entities do not throw', async (t) => {
const { store, view } = await createView('graceful')
// None of these should throw
await apply([
node({ type: 'update-user', userId: 'ghost', name: 'X', timestamp: 1 }),
node({ type: 'link-identity', identityId: 'ghost', userId: 'ghost', timestamp: 1 }),
node({ type: 'unlink-identity', identityId: 'ghost', timestamp: 1 }),
node({ type: 'revoke-identity', identityId: 'ghost', timestamp: 1 }),
node({ type: 'restore-identity', identityId: 'ghost', timestamp: 1 }),
node({ type: 'upgrade-permissions', userId: 'ghost', repoName: 'r', permissions: ['x'], timestamp: 1 }),
node({ type: 'merge-accounts', primaryUserId: 'ghost1', secondaryUserId: 'ghost2', timestamp: 1 })
], view, null)
t.pass('all operations handled gracefully')
await view.close()
await store.close()
})
// ========================================
// Cleanup
// ========================================
test('cleanup: remove temp dir', async (t) => {
await fs.promises.rm(tmpDir, { recursive: true, force: true })
t.pass('temp dir removed')
})