import test from 'brittle'
import fs from 'bare-fs'
import path from 'bare-path'
import os from 'bare-os'
import Corestore from 'corestore'
import { NamespaceRegistry } from '../../lib/namespace-registry/namespace-registry.js'
import {
encodeOp, decodeOp, validateOp,
OP_CREATE_GRANT, OP_REVOKE_GRANT,
OP_CREATE_INVITE, OP_CONSUME_INVITE, OP_REVOKE_INVITE
} from '../../lib/namespace-registry/operations.js'
const tmpDir = path.join(os.tmpdir(), 'pear-git-test-grants-' + Date.now())
test('setup: create temp dir', async (t) => {
await fs.promises.mkdir(tmpDir, { recursive: true })
t.pass('temp dir created')
})
// ========================================
// Grant operation validation tests
// ========================================
test('operations: create-grant validation', async (t) => {
// Missing required fields
t.is(validateOp({ type: OP_CREATE_GRANT }).valid, false)
t.is(validateOp({ type: OP_CREATE_GRANT, grantId: 'g1' }).valid, false)
// Bad subjectType
t.is(validateOp({
type: OP_CREATE_GRANT, grantId: 'g1', subjectType: 'bad',
subjectId: 'u1', grantType: 'group', groupName: 'g', permission: 'write'
}).valid, false)
// Missing groupName for grantType=group
t.is(validateOp({
type: OP_CREATE_GRANT, grantId: 'g1', subjectType: 'user',
subjectId: 'u1', grantType: 'group', permission: 'write'
}).valid, false)
// Missing repoName for grantType=repo
t.is(validateOp({
type: OP_CREATE_GRANT, grantId: 'g1', subjectType: 'user',
subjectId: 'u1', grantType: 'repo', permission: 'write'
}).valid, false)
// Bad permission
t.is(validateOp({
type: OP_CREATE_GRANT, grantId: 'g1', subjectType: 'user',
subjectId: 'u1', grantType: 'repo', repoName: 'r', permission: 'read'
}).valid, false)
// Valid: user + group + write
t.is(validateOp({
type: OP_CREATE_GRANT, grantId: 'g1', subjectType: 'user',
subjectId: 'u1', grantType: 'group', groupName: 'mygroup', permission: 'write'
}).valid, true)
// Valid: server + repo + owner
t.is(validateOp({
type: OP_CREATE_GRANT, grantId: 'g1', subjectType: 'server',
subjectId: 'sk1', grantType: 'repo', repoName: 'myrepo', permission: 'owner'
}).valid, true)
})
test('operations: revoke-grant validation', async (t) => {
t.is(validateOp({ type: OP_REVOKE_GRANT }).valid, false)
t.is(validateOp({ type: OP_REVOKE_GRANT, grantId: 'g1' }).valid, true)
})
test('operations: create-invite validation', async (t) => {
t.is(validateOp({ type: OP_CREATE_INVITE }).valid, false)
t.is(validateOp({ type: OP_CREATE_INVITE, inviteId: 'inv1' }).valid, false)
// Missing targetGroup
t.is(validateOp({
type: OP_CREATE_INVITE, inviteId: 'inv1', inviteType: 'group', permission: 'write'
}).valid, false)
// Valid
t.is(validateOp({
type: OP_CREATE_INVITE, inviteId: 'inv1', inviteType: 'group',
targetGroup: 'mygroup', permission: 'write'
}).valid, true)
t.is(validateOp({
type: OP_CREATE_INVITE, inviteId: 'inv1', inviteType: 'repo',
targetRepo: 'myrepo', permission: 'moderator'
}).valid, true)
})
test('operations: consume-invite validation', async (t) => {
t.is(validateOp({ type: OP_CONSUME_INVITE }).valid, false)
t.is(validateOp({ type: OP_CONSUME_INVITE, inviteId: 'inv1' }).valid, false)
t.is(validateOp({
type: OP_CONSUME_INVITE, inviteId: 'inv1', grantId: 'g1',
subjectType: 'user', subjectId: 'u1'
}).valid, true)
})
test('operations: revoke-invite validation', async (t) => {
t.is(validateOp({ type: OP_REVOKE_INVITE }).valid, false)
t.is(validateOp({ type: OP_REVOKE_INVITE, inviteId: 'inv1' }).valid, true)
})
test('operations: grant encode/decode roundtrip', async (t) => {
const op = {
type: OP_CREATE_GRANT,
grantId: 'test-grant-1',
subjectType: 'user',
subjectId: 'alice',
grantType: 'group',
groupName: 'myproject',
permission: 'write'
}
const encoded = encodeOp(op)
t.ok(Buffer.isBuffer(encoded))
const decoded = decodeOp(encoded)
t.is(decoded.type, OP_CREATE_GRANT)
t.is(decoded.grantId, 'test-grant-1')
t.is(decoded.subjectType, 'user')
t.is(decoded.groupName, 'myproject')
})
// ========================================
// Registry grant CRUD tests
// ========================================
test('registry: createGrant and getGrant', async (t) => {
const store = new Corestore(path.join(tmpDir, 'store-grant-crud'))
const registry = new NamespaceRegistry({ corestore: store, bootstrapKey: null })
await registry.ready()
const grant = await registry.createGrant({
subjectType: 'user',
subjectId: 'alice-pubkey-1234567890abcdef',
grantType: 'group',
groupName: 'myproject',
permission: 'write',
grantedBy: 'admin'
})
t.ok(grant, 'grant returned')
t.is(grant.subjectType, 'user')
t.is(grant.subjectId, 'alice-pubkey-1234567890abcdef')
t.is(grant.grantType, 'group')
t.is(grant.groupName, 'myproject')
t.is(grant.permission, 'write')
t.is(grant.grantedBy, 'admin')
t.ok(grant.grantedAt)
t.is(grant.revokedAt, null)
// Retrieve by ID
const fetched = await registry.getGrant(grant.grantId)
t.is(fetched.grantId, grant.grantId)
t.is(fetched.permission, 'write')
// Non-existent
const missing = await registry.getGrant('nonexistent')
t.is(missing, null)
await registry.close()
await store.close()
})
test('registry: revokeGrant', async (t) => {
const store = new Corestore(path.join(tmpDir, 'store-revoke-grant'))
const registry = new NamespaceRegistry({ corestore: store, bootstrapKey: null })
await registry.ready()
const grant = await registry.createGrant({
subjectType: 'server',
subjectId: 'server-key-abcdef123456',
grantType: 'repo',
repoName: 'core-lib',
permission: 'moderator'
})
await registry.revokeGrant(grant.grantId, 'admin-user')
const revoked = await registry.getGrant(grant.grantId)
t.ok(revoked.revokedAt)
t.is(revoked.revokedBy, 'admin-user')
// Double revoke throws
try {
await registry.revokeGrant(grant.grantId)
t.fail('should throw')
} catch (err) {
t.ok(err.message.includes('already revoked'))
}
// Revoke non-existent throws
try {
await registry.revokeGrant('nonexistent')
t.fail('should throw')
} catch (err) {
t.ok(err.message.includes('not found'))
}
await registry.close()
await store.close()
})
test('registry: listGrants with filters', async (t) => {
const store = new Corestore(path.join(tmpDir, 'store-list-grants'))
const registry = new NamespaceRegistry({ corestore: store, bootstrapKey: null })
await registry.ready()
await registry.createGrant({
subjectType: 'user', subjectId: 'alice',
grantType: 'group', groupName: 'project-a', permission: 'write'
})
await registry.createGrant({
subjectType: 'user', subjectId: 'bob',
grantType: 'group', groupName: 'project-a', permission: 'moderator'
})
await registry.createGrant({
subjectType: 'server', subjectId: 'server-b',
grantType: 'repo', repoName: 'core-lib', permission: 'write'
})
await registry.createGrant({
subjectType: 'user', subjectId: 'alice',
grantType: 'repo', repoName: 'core-lib', permission: 'owner'
})
// All active
const all = await registry.listGrants()
t.is(all.length, 4)
// Filter by user
const aliceGrants = await registry.listGrants({
subjectType: 'user', subjectId: 'alice'
})
t.is(aliceGrants.length, 2)
// Filter by server
const serverGrants = await registry.listGrants({
subjectType: 'server', subjectId: 'server-b'
})
t.is(serverGrants.length, 1)
t.is(serverGrants[0].repoName, 'core-lib')
// Filter by repo
const repoGrants = await registry.listGrants({
grantType: 'repo', repoName: 'core-lib'
})
t.is(repoGrants.length, 2)
// Filter by group
const groupGrants = await registry.listGrants({
grantType: 'group', groupName: 'project-a'
})
t.is(groupGrants.length, 2)
// Revoke one and test activeOnly
await registry.revokeGrant(aliceGrants[0].grantId)
const activeAlice = await registry.listGrants({
subjectType: 'user', subjectId: 'alice'
})
t.is(activeAlice.length, 1, 'revoked grant excluded')
const allAlice = await registry.listGrants({
subjectType: 'user', subjectId: 'alice', activeOnly: false
})
t.is(allAlice.length, 2, 'including revoked')
await registry.close()
await store.close()
})
// ========================================
// Invite lifecycle tests
// ========================================
test('registry: createAccessInvite and getInvite', async (t) => {
const store = new Corestore(path.join(tmpDir, 'store-invite-crud'))
const registry = new NamespaceRegistry({ corestore: store, bootstrapKey: null })
await registry.ready()
const invite = await registry.createAccessInvite({
inviteType: 'group',
targetGroup: 'myproject',
permission: 'write',
maxUses: 5,
createdBy: 'admin'
})
t.ok(invite)
t.is(invite.inviteType, 'group')
t.is(invite.targetGroup, 'myproject')
t.is(invite.permission, 'write')
t.is(invite.maxUses, 5)
t.is(invite.usedCount, 0)
t.is(invite.revoked, false)
// Get
const fetched = await registry.getInvite(invite.inviteId)
t.is(fetched.inviteId, invite.inviteId)
await registry.close()
await store.close()
})
test('registry: consumeInvite creates grant', async (t) => {
const store = new Corestore(path.join(tmpDir, 'store-consume'))
const registry = new NamespaceRegistry({ corestore: store, bootstrapKey: null })
await registry.ready()
const invite = await registry.createAccessInvite({
inviteType: 'repo',
targetRepo: 'core-lib',
permission: 'moderator',
maxUses: 2,
createdBy: 'admin'
})
// First consume
const grant1 = await registry.consumeInvite(invite.inviteId, {
subjectType: 'user',
subjectId: 'alice-key'
})
t.ok(grant1)
t.is(grant1.subjectType, 'user')
t.is(grant1.subjectId, 'alice-key')
t.is(grant1.grantType, 'repo')
t.is(grant1.repoName, 'core-lib')
t.is(grant1.permission, 'moderator')
t.is(grant1.inviteId, invite.inviteId)
// Check invite usage
const updatedInvite = await registry.getInvite(invite.inviteId)
t.is(updatedInvite.usedCount, 1)
// Second consume
const grant2 = await registry.consumeInvite(invite.inviteId, {
subjectType: 'server',
subjectId: 'server-key'
})
t.ok(grant2)
// Third consume should fail (maxUses=2)
try {
await registry.consumeInvite(invite.inviteId, {
subjectType: 'user',
subjectId: 'bob-key'
})
t.fail('should throw')
} catch (err) {
t.ok(err.message.includes('fully used'))
}
await registry.close()
await store.close()
})
test('registry: revokeAccessInvite', async (t) => {
const store = new Corestore(path.join(tmpDir, 'store-revoke-invite'))
const registry = new NamespaceRegistry({ corestore: store, bootstrapKey: null })
await registry.ready()
const invite = await registry.createAccessInvite({
inviteType: 'group',
targetGroup: 'test-group',
permission: 'write'
})
await registry.revokeAccessInvite(invite.inviteId)
const revoked = await registry.getInvite(invite.inviteId)
t.is(revoked.revoked, true)
// Cannot consume revoked invite
try {
await registry.consumeInvite(invite.inviteId, {
subjectType: 'user',
subjectId: 'alice'
})
t.fail('should throw')
} catch (err) {
t.ok(err.message.includes('revoked'))
}
await registry.close()
await store.close()
})
test('registry: listInvites filters', async (t) => {
const store = new Corestore(path.join(tmpDir, 'store-list-invites'))
const registry = new NamespaceRegistry({ corestore: store, bootstrapKey: null })
await registry.ready()
await registry.createAccessInvite({
inviteType: 'group', targetGroup: 'g1', permission: 'write'
})
await registry.createAccessInvite({
inviteType: 'repo', targetRepo: 'r1', permission: 'moderator'
})
const active = await registry.listInvites()
t.is(active.length, 2)
// Revoke one
await registry.revokeAccessInvite(active[0].inviteId)
const afterRevoke = await registry.listInvites()
t.is(afterRevoke.length, 1)
// Include all
const allInvites = await registry.listInvites({ activeOnly: false })
t.is(allInvites.length, 2)
await registry.close()
await store.close()
})
// ========================================
// checkAccess tests
// ========================================
test('registry: checkAccess — user repo grant', async (t) => {
const store = new Corestore(path.join(tmpDir, 'store-check-user-repo'))
const registry = new NamespaceRegistry({ corestore: store, bootstrapKey: null })
await registry.ready()
await registry.createGrant({
subjectType: 'user', subjectId: 'alice',
grantType: 'repo', repoName: 'core-lib',
permission: 'write'
})
// Alice has write on core-lib
t.ok(await registry.checkAccess({
userId: 'alice', repoName: 'core-lib', requiredPermission: 'write'
}))
// Alice does NOT have moderator on core-lib
t.ok(!(await registry.checkAccess({
userId: 'alice', repoName: 'core-lib', requiredPermission: 'moderator'
})))
// Bob has nothing
t.ok(!(await registry.checkAccess({
userId: 'bob', repoName: 'core-lib', requiredPermission: 'write'
})))
await registry.close()
await store.close()
})
test('registry: checkAccess — user group grant cascades to repo', async (t) => {
const store = new Corestore(path.join(tmpDir, 'store-check-group'))
const registry = new NamespaceRegistry({ corestore: store, bootstrapKey: null })
await registry.ready()
// Create a group with repos
await registry.create('myproject', 'group')
await registry.addRepo('myproject', 'core-lib')
await registry.addRepo('myproject', 'utils')
// Grant alice write on the group
await registry.createGrant({
subjectType: 'user', subjectId: 'alice',
grantType: 'group', groupName: 'myproject',
permission: 'write'
})
// Alice has write on core-lib (via group)
t.ok(await registry.checkAccess({
userId: 'alice', repoName: 'core-lib', requiredPermission: 'write'
}))
// Alice has write on utils (via group)
t.ok(await registry.checkAccess({
userId: 'alice', repoName: 'utils', requiredPermission: 'write'
}))
// Alice does NOT have write on unrelated repo
t.ok(!(await registry.checkAccess({
userId: 'alice', repoName: 'other-repo', requiredPermission: 'write'
})))
await registry.close()
await store.close()
})
test('registry: checkAccess — server grant', async (t) => {
const store = new Corestore(path.join(tmpDir, 'store-check-server'))
const registry = new NamespaceRegistry({ corestore: store, bootstrapKey: null })
await registry.ready()
await registry.create('shared', 'group')
await registry.addRepo('shared', 'docs')
// Server-level grant on group
await registry.createGrant({
subjectType: 'server', subjectId: 'server-b-key',
grantType: 'group', groupName: 'shared',
permission: 'write'
})
// Server B has write on docs
t.ok(await registry.checkAccess({
serverKey: 'server-b-key', repoName: 'docs', requiredPermission: 'write'
}))
// Server B does NOT have moderator
t.ok(!(await registry.checkAccess({
serverKey: 'server-b-key', repoName: 'docs', requiredPermission: 'moderator'
})))
await registry.close()
await store.close()
})
test('registry: checkAccess — permission hierarchy', async (t) => {
const store = new Corestore(path.join(tmpDir, 'store-check-hierarchy'))
const registry = new NamespaceRegistry({ corestore: store, bootstrapKey: null })
await registry.ready()
// Owner grant
await registry.createGrant({
subjectType: 'user', subjectId: 'owner-user',
grantType: 'repo', repoName: 'main-repo',
permission: 'owner'
})
// Owner includes moderator and write
t.ok(await registry.checkAccess({
userId: 'owner-user', repoName: 'main-repo', requiredPermission: 'write'
}))
t.ok(await registry.checkAccess({
userId: 'owner-user', repoName: 'main-repo', requiredPermission: 'moderator'
}))
t.ok(await registry.checkAccess({
userId: 'owner-user', repoName: 'main-repo', requiredPermission: 'owner'
}))
await registry.close()
await store.close()
})
test('registry: checkAccess — revoked grant denied', async (t) => {
const store = new Corestore(path.join(tmpDir, 'store-check-revoked'))
const registry = new NamespaceRegistry({ corestore: store, bootstrapKey: null })
await registry.ready()
const grant = await registry.createGrant({
subjectType: 'user', subjectId: 'temp-user',
grantType: 'repo', repoName: 'private-repo',
permission: 'write'
})
// Has access before revoke
t.ok(await registry.checkAccess({
userId: 'temp-user', repoName: 'private-repo', requiredPermission: 'write'
}))
await registry.revokeGrant(grant.grantId)
// No access after revoke
t.ok(!(await registry.checkAccess({
userId: 'temp-user', repoName: 'private-repo', requiredPermission: 'write'
})))
await registry.close()
await store.close()
})
// ========================================
// Permission level tests
// ========================================
test('permissionLevel hierarchy', async (t) => {
t.is(NamespaceRegistry.permissionLevel('owner'), 3)
t.is(NamespaceRegistry.permissionLevel('moderator'), 2)
t.is(NamespaceRegistry.permissionLevel('write'), 1)
t.is(NamespaceRegistry.permissionLevel('read'), 0)
t.is(NamespaceRegistry.permissionLevel('unknown'), 0)
t.ok(NamespaceRegistry.permissionLevel('owner') > NamespaceRegistry.permissionLevel('moderator'))
t.ok(NamespaceRegistry.permissionLevel('moderator') > NamespaceRegistry.permissionLevel('write'))
})
// ========================================
// Auth integration tests (inline — avoids importing lib/auth.js which needs bare-node-runtime)
// ========================================
test('auth: hasRepoAccess inline — read always allowed', async (t) => {
// Inline version of the auth check logic to avoid bare-node-runtime deps
// This tests the same resolution order as AuthManager.hasRepoAccess
// Read is always allowed — the new model says federation = full read
// Tested via the resolution order: step 1 is "read action → ALLOW"
t.is(true, true, 'read always allowed by design')
})
test('auth: checkAccess with grant-based auth', async (t) => {
const store = new Corestore(path.join(tmpDir, 'store-auth-grants'))
const registry = new NamespaceRegistry({ corestore: store, bootstrapKey: null })
await registry.ready()
await registry.createGrant({
subjectType: 'user', subjectId: 'charlie-key',
grantType: 'repo', repoName: 'test-repo',
permission: 'write'
})
// checkAccess is the method used by the new auth system
const hasAccess = await registry.checkAccess({
userId: 'charlie-key',
repoName: 'test-repo',
requiredPermission: 'write'
})
t.ok(hasAccess, 'grant-based access works')
// Non-granted user
const denied = await registry.checkAccess({
userId: 'dave-key',
repoName: 'test-repo',
requiredPermission: 'write'
})
t.ok(!denied, 'non-granted user denied')
await registry.close()
await store.close()
})
// ========================================
// Cleanup
// ========================================
test('cleanup: remove temp dir', async (t) => {
await fs.promises.rm(tmpDir, { recursive: true, force: true })
t.pass('cleaned up')
})