OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-note / test / unit / sharing.test.js

221 lines · javascript ref:
import test from 'brittle'
import { MockRegistry } from '../helpers/fixtures.js'
import { BearerTokenProvider } from '../../lib/auth/providers/bearer-token.js'

const SHARE_PREFIX = 'share:'

// BearerTokenProvider expects IdentityRegistry which wraps Hyperbee as .db
function wrapRegistry (mockRegistry) {
  return { db: mockRegistry, isRevoked: async () => false }
}

// Helper — create a share record (mimics what the route does)
async function createShare (registry, bearerProvider, slug, options = {}) {
  const { permissions = ['read'], label = '', username = 'admin' } = options

  const tokenResult = await bearerProvider.createToken({
    username: `share:${username}`,
    description: `Share: ${slug} (${label || permissions.join(', ')})`,
    permissions,
    notebooks: [slug],
    expiresAt: null
  })

  const shareKey = `${SHARE_PREFIX}${slug}:${tokenResult.tokenHash}`
  await registry.put(shareKey, {
    notebook: slug,
    tokenHash: tokenResult.tokenHash,
    permissions,
    sharedBy: username,
    sharedAt: Date.now(),
    label
  })

  return tokenResult
}

// Helper — list shares for a notebook
async function listShares (registry, slug) {
  const prefix = `${SHARE_PREFIX}${slug}:`
  const shares = []
  for await (const entry of registry.createReadStream({
    gt: prefix,
    lt: prefix + '\xff'
  })) {
    shares.push(entry.value)
  }
  return shares
}

// Helper — revoke a share
async function revokeShare (registry, bearerProvider, slug, tokenHash) {
  const shareKey = `${SHARE_PREFIX}${slug}:${tokenHash}`
  await bearerProvider.revokeToken(tokenHash)
  await registry.del(shareKey)
}

// ==================== SHARE CREATION ====================

test('create share — returns token and stores record', async (t) => {
  const registry = new MockRegistry()
  const bearer = new BearerTokenProvider(wrapRegistry(registry))

  const result = await createShare(registry, bearer, 'my-notebook', {
    permissions: ['read'],
    label: 'For Alice'
  })

  t.ok(result.token, 'returns token')
  t.ok(result.tokenHash, 'returns tokenHash')
  t.is(result.token.length, 64, 'token is 64 hex chars (32 bytes)')

  // Share record exists
  const shares = await listShares(registry, 'my-notebook')
  t.is(shares.length, 1)
  t.is(shares[0].notebook, 'my-notebook')
  t.is(shares[0].label, 'For Alice')
  t.alike(shares[0].permissions, ['read'])
  t.is(shares[0].sharedBy, 'admin')
  t.ok(shares[0].sharedAt)
})

test('create share — with write permissions', async (t) => {
  const registry = new MockRegistry()
  const bearer = new BearerTokenProvider(wrapRegistry(registry))

  const result = await createShare(registry, bearer, 'collab-notebook', {
    permissions: ['read', 'write'],
    label: 'Collaborator'
  })

  // Token has write permission
  const tokenData = await bearer.validateToken(result.token)
  t.ok(tokenData)
  t.alike(tokenData.permissions, ['read', 'write'])
  t.alike(tokenData.notebooks, ['collab-notebook'])
})

test('create share — token scoped to specific notebook', async (t) => {
  const registry = new MockRegistry()
  const bearer = new BearerTokenProvider(wrapRegistry(registry))

  const result = await createShare(registry, bearer, 'scoped-notebook', {
    permissions: ['read']
  })

  const tokenData = await bearer.validateToken(result.token)
  t.alike(tokenData.notebooks, ['scoped-notebook'], 'token scoped to notebook')
})

// ==================== LISTING SHARES ====================

test('list shares — returns all shares for a notebook', async (t) => {
  const registry = new MockRegistry()
  const bearer = new BearerTokenProvider(wrapRegistry(registry))

  await createShare(registry, bearer, 'shared-nb', { label: 'Alice', permissions: ['read'] })
  await createShare(registry, bearer, 'shared-nb', { label: 'Bob', permissions: ['read', 'write'] })
  await createShare(registry, bearer, 'other-nb', { label: 'Eve', permissions: ['read'] })

  const shares = await listShares(registry, 'shared-nb')
  t.is(shares.length, 2, 'only shares for this notebook')

  const labels = shares.map(s => s.label).sort()
  t.alike(labels, ['Alice', 'Bob'])
})

test('list shares — empty for unshared notebook', async (t) => {
  const registry = new MockRegistry()

  const shares = await listShares(registry, 'no-shares')
  t.is(shares.length, 0)
})

// ==================== REVOKING SHARES ====================

test('revoke share — removes record and revokes token', async (t) => {
  const registry = new MockRegistry()
  const bearer = new BearerTokenProvider(wrapRegistry(registry))

  const result = await createShare(registry, bearer, 'revoke-test', { label: 'Temporary' })

  // Token works before revocation
  const valid = await bearer.validateToken(result.token)
  t.ok(valid, 'token valid before revocation')

  // Revoke
  await revokeShare(registry, bearer, 'revoke-test', result.tokenHash)

  // Share record gone
  const shares = await listShares(registry, 'revoke-test')
  t.is(shares.length, 0, 'share record removed')

  // Token no longer valid
  const invalid = await bearer.validateToken(result.token)
  t.is(invalid, null, 'token invalid after revocation')
})

test('revoke share — does not affect other shares', async (t) => {
  const registry = new MockRegistry()
  const bearer = new BearerTokenProvider(wrapRegistry(registry))

  const share1 = await createShare(registry, bearer, 'multi-share', { label: 'Keep' })
  const share2 = await createShare(registry, bearer, 'multi-share', { label: 'Revoke' })

  await revokeShare(registry, bearer, 'multi-share', share2.tokenHash)

  // share1 still works
  const valid = await bearer.validateToken(share1.token)
  t.ok(valid, 'other share still valid')

  const shares = await listShares(registry, 'multi-share')
  t.is(shares.length, 1)
  t.is(shares[0].label, 'Keep')
})

// ==================== TOKEN AUTHENTICATION ====================

test('share token — authenticates with correct permissions', async (t) => {
  const registry = new MockRegistry()
  const bearer = new BearerTokenProvider(wrapRegistry(registry))

  const result = await createShare(registry, bearer, 'auth-test', {
    permissions: ['read'],
    username: 'owner'
  })

  // Simulate authentication
  const authResult = await bearer.authenticate(result.token)
  t.ok(authResult.verified)
  t.is(authResult.method, 'bearer-token')
  t.alike(authResult.permissions, ['read'])
  t.alike(authResult.notebooks, ['auth-test'])
  t.is(authResult.credentials.username, 'share:owner')
})

test('share token — read-only cannot write', async (t) => {
  const registry = new MockRegistry()
  const bearer = new BearerTokenProvider(wrapRegistry(registry))

  const result = await createShare(registry, bearer, 'readonly-test', {
    permissions: ['read']
  })

  const authResult = await bearer.authenticate(result.token)
  t.alike(authResult.permissions, ['read'])
  t.not(authResult.permissions.includes('write'), 'no write permission')
})

test('share token — write token has write permission', async (t) => {
  const registry = new MockRegistry()
  const bearer = new BearerTokenProvider(wrapRegistry(registry))

  const result = await createShare(registry, bearer, 'write-test', {
    permissions: ['read', 'write']
  })

  const authResult = await bearer.authenticate(result.token)
  t.ok(authResult.permissions.includes('read'))
  t.ok(authResult.permissions.includes('write'))
})