OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git / test / unit / identity-apply.test.js

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