OverviewFilesCommitsIssuesDocsReleasesPeers

favela-web / test / gate-policy.test.mjs

68 lines · javascript ref:
// Gate policy — the two pure decisions behind 5B: who counts as a member, and whether the SSR site
// renders for an anonymous visitor. Kept pure (no P2P, no HTTP) so the rules are testable directly.
// Run: node test/gate-policy.test.mjs
import { makeIsMember, siteVisibility, isPublicPath, PUBLIC, MEMBERS } from '../lib/gate-policy.js'

let pass = 0
const ok = (c, m) => { if (!c) { console.error('FAIL:', m); process.exit(1) } console.log('  ok -', m); pass++ }
const eq = (a, b, m) => ok(a === b, m + ` (got ${JSON.stringify(a)})`)

const DID = 'did:plc:memberaaaaaaaaaaaaaaaaa'
const OTHER = 'did:plc:strangerbbbbbbbbbbbbbbb'

// ── makeIsMember ────────────────────────────────────────────────────
{
  const communities = { communitiesFor: async (did) => (did === DID ? [{ id: 'loser-club' }] : []) }
  const isMember = makeIsMember(communities)
  eq(await isMember(DID), true, 'a DID on a roster is a member')
  eq(await isMember(OTHER), false, 'a DID on no roster is not')
  eq(await isMember(null), false, 'no DID is not a member')
}
{
  // Communities.rosterOf already stale-serves and returns an empty Set on failure, so "not a member"
  // is what an unreachable room produces. Membership must therefore FAIL CLOSED here too — and a
  // resolver that throws outright must not become an open door.
  const boom = { communitiesFor: async () => { throw new Error('room unreachable') } }
  eq(await makeIsMember(boom)(DID), false, 'a membership lookup that throws denies')
  eq(await makeIsMember(null)(DID), false, 'no community registry at all denies')
}

// ── siteVisibility ──────────────────────────────────────────────────
{
  // DEFAULT IS PUBLIC. This flag exists to let a community CLOSE its site; a node that has never set
  // it must keep rendering exactly as it does today, or shipping the flag is itself an outage.
  eq(siteVisibility([]), PUBLIC, 'no communities configured → public (unchanged behaviour)')
  eq(siteVisibility([{}]), PUBLIC, 'a community with no visibility meta → public')
  eq(siteVisibility([{ visibility: 'public' }]), PUBLIC, 'explicitly public → public')
  eq(siteVisibility([{ visibility: 'members' }]), MEMBERS, 'a lone members-only community closes the site')
  // A node hosting several communities: closing the site would hide the OTHER community's public
  // content, so one public community keeps the door open. The stricter rule belongs per-community,
  // and that needs per-community routing this node does not have yet.
  eq(siteVisibility([{ visibility: 'members' }, { visibility: 'public' }]), PUBLIC,
    'mixed → public, because closing would hide a community that chose to be open')
  eq(siteVisibility([{ visibility: 'members' }, { visibility: 'members' }]), MEMBERS, 'all members-only → closed')
  eq(siteVisibility([{ visibility: 'MEMBERS' }]), MEMBERS, 'value is case-insensitive')
  eq(siteVisibility([{ visibility: ' members ' }]), MEMBERS, 'and whitespace-tolerant')
  eq(siteVisibility([{ visibility: 'nonsense' }]), PUBLIC, 'an unrecognised value is NOT treated as closed')
  eq(siteVisibility(null), PUBLIC, 'null input → public, never a crash')
}

// ── isPublicPath — the exemptions that keep a closed site usable ────
{
  // The way IN must never be gated, or "members only" becomes "nobody, ever".
  for (const p of ['/login', '/logout', '/api/auth/challenge', '/api/auth/login', '/api/auth/pair', '/static/app.css', '/auth/handoff']) {
    ok(isPublicPath(p), `sign-in path stays reachable: ${p}`)
  }
  // bsky calls the lens ANONYMOUSLY — gating it would break the feed the community publishes on purpose.
  ok(isPublicPath('/xrpc/app.bsky.feed.getFeedSkeleton'), 'lens export stays public')
  ok(isPublicPath('/.well-known/did.json'), 'identity resolution stays public')
  // Everything the site actually renders IS gated.
  for (const p of ['/', '/profile/did:plc:x', '/post/abc', '/search', '/notifications']) {
    ok(!isPublicPath(p), `content path is gated: ${p}`)
  }
  ok(!isPublicPath('/api/timeline'), 'a JSON read of content is gated too')
  ok(!isPublicPath(''), 'empty path is not exempt')
}

console.log(`\n${pass} assertions passed`)