Files
hackerspace.sh/__tests__/governance.test.ts
T
Moheeb Zara ea8d7a5f91 feat: governance kernel, deployment hardening, local-dev stack
Adds proposals + proposal_votes + policies + incidents + incident_updates
tables with full RLS, async voting flow, dismissal-appeal pathway,
versioned policies with provenance, and an anonymous-incident token mode.
Adds per-space configurable areas, financial visibility settings, member
self-profile + COI disclosure, and a mission statement that renders in
the app footer.

Reorganises lib/actions.ts into per-domain files (tasks, projects,
members, payments, contacts, knowledge-base, secrets, area-leads,
settings, imports, proposals, incidents, policies, areas) plus a barrel.
Adds lib/permissions.ts and lib/auth-helpers.ts so action bodies stop
duplicating the user-to-member-to-role check. Wires every action that has
a Zod schema; introduces a flexibleDateTime preprocessor so HTML date
pickers no longer hit "Invalid datetime".

Hardens RLS: drops NOT NULL on space_members.user_id for offline members,
adds a privilege-escalation trigger that blocks self-edits to role / tier
/ status / approved / has_card_access / space_id, closes four
cross-tenant holes in the governance kernel, and adds a partial unique
index so two admins cannot concurrently activate two drafts of the same
policy slug.

Ships every deployment artifact: Dockerfile, docker-compose,
.do/app.yaml, .env.example, /api/health, plus a managed-Supabase guide
(docs/DEPLOYMENT.md), an end-to-end self-hosted Droplet guide
(docs/DEPLOY_DO_SELFHOSTED.md), and a Supabase-CLI local-dev quickstart
(docs/LOCAL_DEV.md). Replaces middleware.ts with proxy.ts for Next.js 16.
Adds scripts/schema.sql as the canonical idempotent full deploy plus
fourteen numbered incremental migrations.

Adds scripts/seed.sql so a fresh local DB renders something instead of
empty pages: a demo space, five members across all roles, a v1 code of
conduct, three proposals in different states with seven votes already
cast, an open incident, a project, three tasks, a KB entry, and matching
activity-log rows.

Replaces v0-generated test scaffolding with 266 real tests across
__tests__/{actions,auth-helpers,components,governance,permissions,
security,utils,validations}.test.ts. Adds react-markdown + remark-gfm
for proposal / incident / policy body rendering. Deletes four dead v0
fix-* scripts, the unused components/app-shell.tsx, and the duplicate
app/auth/login/page.tsx.

Routes (31 total): /api/health, /api/paypal/sync, /auth/callback, /,
/login, /signup, /signup/confirm, /dashboard, /tasks, /projects,
/members, /payments, /financials, /contacts, /ops, /ops/[id], /ops/new,
/proposals, /proposals/[id], /proposals/new, /incidents, /incidents/[id],
/incidents/new, /policies, /policies/[slug], /policies/new, /profile,
/recruitment, /settings, /comms, /import.

Full pass-by-pass history is in docs/HANDOFF.md; the roadmap is in
docs/GOVERNANCE_FEATURES.md.
2026-05-14 04:52:11 -07:00

445 lines
14 KiB
TypeScript

import { describe, it, expect } from 'vitest'
import {
createProposalSchema,
castVoteSchema,
decideProposalSchema,
withdrawProposalSchema,
fileIncidentSchema,
updateIncidentStatusSchema,
addIncidentUpdateSchema,
appealIncidentSchema,
createPolicySchema,
supersedePolicySchema,
updatePolicyStatusSchema,
proposalTypes,
thresholdRules,
votePositions,
incidentSeverities,
incidentStatuses,
policyStatuses,
} from '@/lib/validations'
const UUID = '11111111-2222-3333-4444-555555555555'
// =============================================================================
// Proposals
// =============================================================================
describe('createProposalSchema', () => {
it('accepts minimal valid input', () => {
const r = createProposalSchema.safeParse({ title: 'Approve new laser station rules' })
expect(r.success).toBe(true)
})
it('defaults proposal_type to advisory_poll', () => {
const r = createProposalSchema.safeParse({ title: 'X' })
expect(r.success).toBe(true)
if (r.success) expect(r.data.proposal_type).toBe('advisory_poll')
})
it('accepts every database proposal_type', () => {
for (const t of proposalTypes) {
const r = createProposalSchema.safeParse({ title: 'X', proposal_type: t })
expect(r.success, `proposal_type=${t}`).toBe(true)
}
})
it('rejects unknown proposal_type', () => {
const r = createProposalSchema.safeParse({ title: 'X', proposal_type: 'referendum' })
expect(r.success).toBe(false)
})
it('rejects empty title', () => {
const r = createProposalSchema.safeParse({ title: '' })
expect(r.success).toBe(false)
})
it('rejects title over 200 chars', () => {
const r = createProposalSchema.safeParse({ title: 'x'.repeat(201) })
expect(r.success).toBe(false)
})
it('accepts every threshold rule', () => {
for (const t of thresholdRules) {
const r = createProposalSchema.safeParse({ title: 'X', threshold: t })
expect(r.success, `threshold=${t}`).toBe(true)
}
})
})
describe('castVoteSchema', () => {
it('accepts a yes vote without recusal reason', () => {
const r = castVoteSchema.safeParse({ proposalId: UUID, position: 'yes' })
expect(r.success).toBe(true)
})
it('accepts a no, abstain vote without recusal reason', () => {
expect(castVoteSchema.safeParse({ proposalId: UUID, position: 'no' }).success).toBe(true)
expect(castVoteSchema.safeParse({ proposalId: UUID, position: 'abstain' }).success).toBe(true)
})
it('rejects a recused vote without a reason', () => {
const r = castVoteSchema.safeParse({ proposalId: UUID, position: 'recused' })
expect(r.success).toBe(false)
})
it('rejects a recused vote with an empty-string reason', () => {
const r = castVoteSchema.safeParse({ proposalId: UUID, position: 'recused', recusal_reason: ' ' })
expect(r.success).toBe(false)
})
it('accepts a recused vote with a real reason', () => {
const r = castVoteSchema.safeParse({
proposalId: UUID,
position: 'recused',
recusal_reason: 'I am a named party in the underlying incident.',
})
expect(r.success).toBe(true)
})
it('accepts every vote position', () => {
for (const p of votePositions) {
const r = castVoteSchema.safeParse({
proposalId: UUID,
position: p,
recusal_reason: p === 'recused' ? 'reason' : undefined,
})
expect(r.success, `position=${p}`).toBe(true)
}
})
it('rejects invalid proposal id', () => {
expect(castVoteSchema.safeParse({ proposalId: 'nope', position: 'yes' }).success).toBe(false)
})
})
describe('decideProposalSchema and withdrawProposalSchema', () => {
it('require a uuid', () => {
expect(decideProposalSchema.safeParse({ proposalId: UUID }).success).toBe(true)
expect(decideProposalSchema.safeParse({ proposalId: 'bad' }).success).toBe(false)
expect(withdrawProposalSchema.safeParse({ proposalId: UUID }).success).toBe(true)
expect(withdrawProposalSchema.safeParse({ proposalId: 'bad' }).success).toBe(false)
})
})
// =============================================================================
// Incidents
// =============================================================================
describe('fileIncidentSchema', () => {
it('accepts minimal valid input', () => {
const r = fileIncidentSchema.safeParse({
title: 'Unsafe handling at laser station',
body: 'On Tuesday, I observed...',
})
expect(r.success).toBe(true)
})
it('defaults severity to medium and category to general', () => {
const r = fileIncidentSchema.safeParse({ title: 'x', body: 'y' })
expect(r.success).toBe(true)
if (r.success) {
expect(r.data.severity).toBe('medium')
expect(r.data.category).toBe('general')
}
})
it('accepts every severity', () => {
for (const s of incidentSeverities) {
const r = fileIncidentSchema.safeParse({ title: 'x', body: 'y', severity: s })
expect(r.success, `severity=${s}`).toBe(true)
}
})
it('rejects empty title and body', () => {
expect(fileIncidentSchema.safeParse({ title: '', body: 'y' }).success).toBe(false)
expect(fileIncidentSchema.safeParse({ title: 'x', body: '' }).success).toBe(false)
})
it('accepts an array of subject UUIDs', () => {
const r = fileIncidentSchema.safeParse({
title: 'x',
body: 'y',
subjects: [UUID, '22222222-2222-2222-2222-222222222222'],
})
expect(r.success).toBe(true)
})
it('rejects non-uuid subject entries', () => {
const r = fileIncidentSchema.safeParse({ title: 'x', body: 'y', subjects: ['not-a-uuid'] })
expect(r.success).toBe(false)
})
})
describe('updateIncidentStatusSchema', () => {
it('accepts every status', () => {
for (const s of incidentStatuses) {
const r = updateIncidentStatusSchema.safeParse({ incidentId: UUID, status: s })
expect(r.success, `status=${s}`).toBe(true)
}
})
it('allows a disposition when deciding', () => {
const r = updateIncidentStatusSchema.safeParse({
incidentId: UUID,
status: 'decided',
disposition: 'No violation found.',
})
expect(r.success).toBe(true)
})
it('rejects unknown status', () => {
expect(updateIncidentStatusSchema.safeParse({ incidentId: UUID, status: 'pending' }).success).toBe(false)
})
})
describe('addIncidentUpdateSchema', () => {
it('requires a body', () => {
expect(addIncidentUpdateSchema.safeParse({ incidentId: UUID, body: '' }).success).toBe(false)
expect(addIncidentUpdateSchema.safeParse({ incidentId: UUID, body: 'note' }).success).toBe(true)
})
it('defaults visibility to all_parties', () => {
const r = addIncidentUpdateSchema.safeParse({ incidentId: UUID, body: 'note' })
expect(r.success).toBe(true)
if (r.success) expect(r.data.visibility).toBe('all_parties')
})
it('rejects unknown visibility', () => {
const r = addIncidentUpdateSchema.safeParse({
incidentId: UUID,
body: 'note',
visibility: 'whisper',
})
expect(r.success).toBe(false)
})
})
describe('appealIncidentSchema', () => {
it('accepts a valid appeal payload', () => {
const r = appealIncidentSchema.safeParse({
incidentId: UUID,
title: 'Appeal of dismissed complaint',
body: 'I disagree with the board...',
})
expect(r.success).toBe(true)
})
it('rejects empty title', () => {
expect(appealIncidentSchema.safeParse({ incidentId: UUID, title: '', body: '' }).success).toBe(false)
})
})
// =============================================================================
// Policies
// =============================================================================
describe('createPolicySchema', () => {
it('accepts minimal valid input', () => {
const r = createPolicySchema.safeParse({ slug: 'code-of-conduct', title: 'Code of Conduct' })
expect(r.success).toBe(true)
})
it('enforces slug character set', () => {
expect(createPolicySchema.safeParse({ slug: 'CoC', title: 'X' }).success).toBe(false)
expect(createPolicySchema.safeParse({ slug: 'code of conduct', title: 'X' }).success).toBe(false)
expect(createPolicySchema.safeParse({ slug: 'code-of-conduct-2', title: 'X' }).success).toBe(true)
})
it('rejects empty slug and title', () => {
expect(createPolicySchema.safeParse({ slug: '', title: 'X' }).success).toBe(false)
expect(createPolicySchema.safeParse({ slug: 'x', title: '' }).success).toBe(false)
})
it('accepts a plain-language body alongside formal', () => {
const r = createPolicySchema.safeParse({
slug: 'bylaws',
title: 'Bylaws',
body_formal: 'WHEREAS...',
body_plain: 'In short: be excellent to each other.',
})
expect(r.success).toBe(true)
})
})
describe('supersedePolicySchema', () => {
it('requires a policy id and accepts new bodies', () => {
const r = supersedePolicySchema.safeParse({
policyId: UUID,
body_formal: 'updated...',
adopted_by_proposal_id: UUID,
})
expect(r.success).toBe(true)
})
it('rejects bad policy id', () => {
expect(supersedePolicySchema.safeParse({ policyId: 'nope' }).success).toBe(false)
})
})
describe('updatePolicyStatusSchema', () => {
it('accepts every policy_status value', () => {
for (const s of policyStatuses) {
const r = updatePolicyStatusSchema.safeParse({ policyId: UUID, status: s })
expect(r.success, `status=${s}`).toBe(true)
}
})
it('rejects unknown status', () => {
expect(updatePolicyStatusSchema.safeParse({ policyId: UUID, status: 'archived' }).success).toBe(false)
})
})
// =============================================================================
// Cross-cutting: type contracts as documentation
// =============================================================================
// =============================================================================
// Member profile and visibility (Tier 2 + Tier 3)
// =============================================================================
import {
updateMyProfileSchema,
discloseAffiliationsSchema,
updateSpaceVisibilitySchema,
meetingMinutesSchema,
financialVisibilities,
directoryVisibilities,
} from '@/lib/validations'
describe('updateMyProfileSchema', () => {
it('accepts empty input (all fields optional)', () => {
expect(updateMyProfileSchema.safeParse({}).success).toBe(true)
})
it('accepts a typical profile update', () => {
const r = updateMyProfileSchema.safeParse({
display_name: 'Alex',
handle: 'alex',
skills: ['woodworking', 'electronics'],
willing_to: ['board_candidate', 'host_volunteer'],
})
expect(r.success).toBe(true)
})
it('rejects skills array longer than 40', () => {
const r = updateMyProfileSchema.safeParse({
skills: Array.from({ length: 41 }, (_, i) => `skill-${i}`),
})
expect(r.success).toBe(false)
})
it('rejects a willing_to entry over 60 chars', () => {
const r = updateMyProfileSchema.safeParse({ willing_to: ['x'.repeat(61)] })
expect(r.success).toBe(false)
})
})
describe('discloseAffiliationsSchema', () => {
it('accepts an empty disclosure (still valid; user has no affiliations)', () => {
const r = discloseAffiliationsSchema.safeParse({ affiliations: [] })
expect(r.success).toBe(true)
})
it('accepts a list of affiliations', () => {
const r = discloseAffiliationsSchema.safeParse({
affiliations: ['ACME Robotics, contractor', 'Friends of the Library, board'],
})
expect(r.success).toBe(true)
})
it('rejects too-long affiliation strings', () => {
const r = discloseAffiliationsSchema.safeParse({ affiliations: ['x'.repeat(201)] })
expect(r.success).toBe(false)
})
})
describe('updateSpaceVisibilitySchema', () => {
it('accepts every financial_visibility value', () => {
for (const v of financialVisibilities) {
const r = updateSpaceVisibilitySchema.safeParse({ financial_visibility: v })
expect(r.success, `financial_visibility=${v}`).toBe(true)
}
})
it('accepts every directory_visibility value', () => {
for (const v of directoryVisibilities) {
const r = updateSpaceVisibilitySchema.safeParse({ member_directory_visibility: v })
expect(r.success, `member_directory_visibility=${v}`).toBe(true)
}
})
it('rejects unknown visibility', () => {
expect(
updateSpaceVisibilitySchema.safeParse({ financial_visibility: 'public' }).success,
).toBe(false)
})
})
describe('meetingMinutesSchema', () => {
it('accepts a typical input', () => {
const r = meetingMinutesSchema.safeParse({
entryId: UUID,
is_meeting_minutes: true,
meeting_date: '2026-05-14T18:00:00Z',
})
expect(r.success).toBe(true)
})
it('defaults is_meeting_minutes to true', () => {
const r = meetingMinutesSchema.safeParse({ entryId: UUID })
expect(r.success).toBe(true)
if (r.success) expect(r.data.is_meeting_minutes).toBe(true)
})
it('rejects invalid entry id', () => {
expect(meetingMinutesSchema.safeParse({ entryId: 'nope' }).success).toBe(false)
})
})
import { updateSpaceSettingsSchema } from '@/lib/validations'
describe('updateSpaceSettingsSchema (mission statement)', () => {
it('accepts a mission statement', () => {
const r = updateSpaceSettingsSchema.safeParse({
mission_statement: 'A community-driven hackerspace committed to radical inclusivity.',
})
expect(r.success).toBe(true)
})
it('accepts a null mission statement (clears the column)', () => {
const r = updateSpaceSettingsSchema.safeParse({ mission_statement: null })
expect(r.success).toBe(true)
})
it('rejects a mission statement longer than 5000 chars', () => {
const r = updateSpaceSettingsSchema.safeParse({ mission_statement: 'x'.repeat(5001) })
expect(r.success).toBe(false)
})
it('still accepts the other space-settings fields', () => {
const r = updateSpaceSettingsSchema.safeParse({
name: 'New name',
city: 'Mesa, AZ',
require_approval: false,
})
expect(r.success).toBe(true)
})
})
describe('governance contract surface', () => {
it('exports the expected proposal_type enum', () => {
expect([...proposalTypes].sort()).toEqual(
['advisory_poll', 'board_action', 'budget', 'bylaw_change', 'membership_vote', 'recall'].sort(),
)
})
it('exports the expected threshold rules in privilege order', () => {
expect([...thresholdRules]).toEqual(['simple_majority', 'two_thirds', 'three_fourths', 'unanimous'])
})
it('exports the expected incident statuses in workflow order', () => {
expect([...incidentStatuses]).toEqual(['received', 'under_review', 'decided', 'appealed', 'closed'])
})
})