// Permission evaluation: formatUserPermissions + hasResourceAccess.
//
// These are the first tests for this path. It carries the resource ACL that
// decides read/write per named resource (repo, notebook, …) across every
// consumer of this package, and it previously resolved a user's grants with a
// single `.find()` that matched the wildcard or the exact name — whichever
// came FIRST in the array. Named grants were therefore shadowed by '*'
// depending on insertion order.
import test from 'brittle'
import { AuthManager } from '../lib/auth.js'
// formatUserPermissions/hasResourceAccess are pure — no registry needed.
//
// But constructing an AuthManager is NOT side-effect free: ChallengeManager and
// RateLimiter each start a setInterval that is never unref'd, so an undestroyed
// instance holds the event loop open and the runner hangs instead of exiting.
// Every instance must be torn down.
function auth (t, opts = {}) {
const m = new AuthManager(opts)
t.teardown(() => m.destroy())
return m
}
const verified = (permissions) => ({ verified: true, permissions })
test('exact-name grant WINS over a wildcard listed first', (t) => {
// The regression. Order here is the hostile one: '*' before the specific
// entry, which is also the order auto-registration produces (every user is
// created with [{'*': read}] and named grants get appended after).
const user = {
globalPermissions: ['read'],
resources: [
{ name: '*', permissions: ['read'] },
{ name: 'secret-repo', permissions: ['read', 'write', 'admin'] }
]
}
const perms = auth(t).formatUserPermissions(user, 'secret-repo')
t.ok(perms.write, 'named write grant is not shadowed by the wildcard')
t.ok(perms.admin, 'named admin grant is not shadowed either')
t.alike(perms.resources, ['secret-repo'], 'reports the specific entry')
})
test('exact-name grant wins regardless of array order', (t) => {
const specificFirst = {
globalPermissions: ['read'],
resources: [
{ name: 'secret-repo', permissions: ['read', 'write'] },
{ name: '*', permissions: ['read'] }
]
}
const wildcardFirst = {
globalPermissions: ['read'],
resources: [
{ name: '*', permissions: ['read'] },
{ name: 'secret-repo', permissions: ['read', 'write'] }
]
}
const a = auth(t).formatUserPermissions(specificFirst, 'secret-repo')
const b = auth(t).formatUserPermissions(wildcardFirst, 'secret-repo')
t.alike(a, b, 'permission evaluation must not depend on array order')
t.ok(a.write)
})
test('an exact grant can be NARROWER than the wildcard', (t) => {
// The case that makes "private except for these people" expressible in
// reverse: broad default, restricted for one resource. Under the old
// first-match behaviour this was unrepresentable when '*' came first.
const user = {
globalPermissions: ['read', 'write'],
resources: [
{ name: '*', permissions: ['read', 'write'] },
{ name: 'locked', permissions: ['read'] }
]
}
const perms = auth(t).formatUserPermissions(user, 'locked')
t.ok(perms.read)
t.absent(perms.write, 'the narrower named grant is honoured')
})
test('wildcard applies when the resource has no entry of its own', (t) => {
const user = {
globalPermissions: ['read'],
resources: [{ name: '*', permissions: ['read', 'write'] }]
}
const perms = auth(t).formatUserPermissions(user, 'anything-else')
t.ok(perms.write, 'falls back to the wildcard')
t.alike(perms.resources, ['*'])
})
test('no matching entry falls back to globalPermissions', (t) => {
const user = {
globalPermissions: ['read', 'admin'],
resources: [{ name: 'other-repo', permissions: ['read'] }]
}
const perms = auth(t).formatUserPermissions(user, 'unrelated')
t.ok(perms.admin, 'global admin applies when no resource entry matches')
t.alike(perms.resources, ['*'])
})
test('null resourceName resolves to the wildcard, not to a named entry', (t) => {
const user = {
globalPermissions: ['read'],
resources: [
{ name: 'some-repo', permissions: ['admin'] },
{ name: '*', permissions: ['read'] }
]
}
const perms = auth(t).formatUserPermissions(user, null)
t.absent(perms.admin, 'a global check must not pick up a named grant')
t.alike(perms.resources, ['*'])
})
test('empty resources falls back to globalPermissions', (t) => {
const perms = auth(t).formatUserPermissions({ globalPermissions: ['read'], resources: [] }, 'x')
t.ok(perms.read)
t.absent(perms.write)
})
test('missing resources array does not throw', (t) => {
const perms = auth(t).formatUserPermissions({ globalPermissions: ['admin'] }, 'x')
t.ok(perms.admin)
})
test('admin implies read and write', (t) => {
const user = { globalPermissions: ['read'], resources: [{ name: 'r', permissions: ['admin'] }] }
const perms = auth(t).formatUserPermissions(user, 'r')
t.ok(perms.read)
t.ok(perms.write)
t.ok(perms.admin)
})
// ── hasResourceAccess ──────────────────────────────────────────
test('hasResourceAccess: unverified auth is always denied', (t) => {
t.absent(auth(t).hasResourceAccess({ verified: false, permissions: { admin: true } }, 'r', 'read'))
t.absent(auth(t).hasResourceAccess(null, 'r', 'read'))
})
test('hasResourceAccess: admin short-circuits', (t) => {
t.ok(auth(t).hasResourceAccess(verified({ admin: true, resources: [] }), 'anything', 'write'))
})
test('hasResourceAccess: action flag must be present', (t) => {
const a = verified({ read: true, write: false, admin: false, resources: ['*'] })
t.ok(auth(t).hasResourceAccess(a, 'r', 'read'))
t.absent(auth(t).hasResourceAccess(a, 'r', 'write'))
})
test('hasResourceAccess: resource must be in scope', (t) => {
const a = verified({ read: true, admin: false, resources: ['repo-a'] })
t.ok(auth(t).hasResourceAccess(a, 'repo-a', 'read'))
t.absent(auth(t).hasResourceAccess(a, 'repo-b', 'read'), 'out-of-scope resource denied')
})
test('hasResourceAccess: an injected checkPermission overrides the default', (t) => {
const seen = []
const m = auth(t, { checkPermission: (a, action, resource) => { seen.push([action, resource]); return true } })
t.ok(m.hasResourceAccess(verified({ read: false, admin: false, resources: [] }), 'r', 'read'))
t.alike(seen, [['read', 'r']], 'called with (auth, action, resourceName)')
})