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.
This commit is contained in:
Moheeb Zara
2026-05-14 04:52:11 -07:00
parent 8b9128704b
commit ea8d7a5f91
99 changed files with 13823 additions and 1940 deletions
+43
View File
@@ -0,0 +1,43 @@
import { describe, it, expect } from 'vitest'
import { z } from 'zod'
import { parseInput } from '@/lib/auth-helpers'
describe('parseInput', () => {
const schema = z.object({
name: z.string().min(1, 'Name is required').max(10, 'Name is too long'),
age: z.number().int().nonnegative(),
})
it('returns parsed data on success', () => {
const r = parseInput(schema, { name: 'Alice', age: 30 })
expect(r.ok).toBe(true)
if (r.ok) {
expect(r.data).toEqual({ name: 'Alice', age: 30 })
}
})
it('returns the first validation message on failure', () => {
const r = parseInput(schema, { name: '', age: 30 })
expect(r.ok).toBe(false)
if (!r.ok) {
expect(r.error).toBe('Name is required')
}
})
it('returns an error for completely invalid input', () => {
const r = parseInput(schema, null)
expect(r.ok).toBe(false)
if (!r.ok) {
expect(r.error).toBeDefined()
}
})
it('returns a fallback message when zod has no error message', () => {
const minimal = z.string()
const r = parseInput(minimal, 42)
expect(r.ok).toBe(false)
if (!r.ok) {
expect(r.error).toBeDefined()
}
})
})
+444
View File
@@ -0,0 +1,444 @@
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'])
})
})
+70
View File
@@ -0,0 +1,70 @@
import { describe, it, expect } from 'vitest'
import {
ROLES,
ADMIN_ROLES,
TREASURER_ROLES,
ACTIVE_STATUSES,
hasRole,
type Role,
} from '@/lib/permissions'
describe('ROLES', () => {
it('lists every database role', () => {
expect([...ROLES].sort()).toEqual(
['admin', 'associate', 'board', 'member', 'treasurer'].sort(),
)
})
})
describe('ADMIN_ROLES', () => {
it('is admin + board', () => {
expect([...ADMIN_ROLES].sort()).toEqual(['admin', 'board'])
})
it('does not include member, treasurer, or associate', () => {
for (const r of ['member', 'treasurer', 'associate'] as Role[]) {
expect(ADMIN_ROLES.includes(r as never)).toBe(false)
}
})
})
describe('TREASURER_ROLES', () => {
it('is admin + board + treasurer', () => {
expect([...TREASURER_ROLES].sort()).toEqual(['admin', 'board', 'treasurer'])
})
it('does not include plain member', () => {
expect(TREASURER_ROLES.includes('member' as never)).toBe(false)
})
})
describe('ACTIVE_STATUSES', () => {
it('includes current, unverified, late', () => {
expect([...ACTIVE_STATUSES].sort()).toEqual(
['current', 'late', 'unverified'].sort(),
)
})
it('does NOT include inactive', () => {
expect(ACTIVE_STATUSES.includes('inactive' as never)).toBe(false)
})
})
describe('hasRole', () => {
it('returns true when role is in the allowed list', () => {
expect(hasRole('admin', ADMIN_ROLES)).toBe(true)
expect(hasRole('board', ADMIN_ROLES)).toBe(true)
expect(hasRole('treasurer', TREASURER_ROLES)).toBe(true)
})
it('returns false when role is not allowed', () => {
expect(hasRole('member', ADMIN_ROLES)).toBe(false)
expect(hasRole('treasurer', ADMIN_ROLES)).toBe(false)
expect(hasRole('associate', TREASURER_ROLES)).toBe(false)
})
it('returns false for null or undefined role', () => {
expect(hasRole(null, ADMIN_ROLES)).toBe(false)
expect(hasRole(undefined, ADMIN_ROLES)).toBe(false)
})
})
+143
View File
@@ -0,0 +1,143 @@
import { describe, it, expect, beforeEach } from 'vitest'
import {
sanitizeString,
stripHtml,
escapeHtml,
sanitizeEmail,
sanitizeUrl,
sanitizeSlug,
hasSqlInjectionPatterns,
isValidUuid,
truncate,
checkRateLimit,
validateContentLength,
} from '@/lib/security'
describe('sanitizeString', () => {
it('strips HTML brackets', () => {
expect(sanitizeString('<script>alert(1)</script>')).toBe('scriptalert(1)/script')
})
it('strips javascript: protocol', () => {
expect(sanitizeString('javascript:alert(1)')).toBe('alert(1)')
})
it('strips event handlers', () => {
expect(sanitizeString('onclick="x"')).toBe('"x"')
})
it('trims whitespace', () => {
expect(sanitizeString(' hello ')).toBe('hello')
})
it('returns empty string for non-strings', () => {
expect(sanitizeString(null as unknown as string)).toBe('')
expect(sanitizeString(undefined as unknown as string)).toBe('')
expect(sanitizeString(42 as unknown as string)).toBe('')
})
})
describe('escapeHtml', () => {
it('escapes the six dangerous characters', () => {
expect(escapeHtml(`<>&"'/`)).toBe('&lt;&gt;&amp;&quot;&#x27;&#x2F;')
})
})
describe('stripHtml', () => {
it('removes tags but keeps content', () => {
expect(stripHtml('<p>hello <b>world</b></p>')).toBe('hello world')
})
})
describe('sanitizeEmail', () => {
it('lowercases and validates', () => {
expect(sanitizeEmail('Foo@Bar.com')).toBe('foo@bar.com')
})
it('rejects malformed addresses', () => {
expect(sanitizeEmail('not-an-email')).toBeNull()
expect(sanitizeEmail('foo@')).toBeNull()
})
})
describe('sanitizeUrl', () => {
it('passes through https', () => {
expect(sanitizeUrl('https://example.com')).toBe('https://example.com/')
})
it('rejects javascript: and file:', () => {
expect(sanitizeUrl('javascript:alert(1)')).toBeNull()
expect(sanitizeUrl('file:///etc/passwd')).toBeNull()
})
})
describe('sanitizeSlug', () => {
it('lowercases and collapses to alphanumeric+hyphen', () => {
expect(sanitizeSlug('Hello World!')).toBe('helloworld')
expect(sanitizeSlug('hello---world')).toBe('hello-world')
})
it('caps length at 50', () => {
const result = sanitizeSlug('a'.repeat(100))
expect(result.length).toBeLessThanOrEqual(50)
})
})
describe('hasSqlInjectionPatterns', () => {
it('flags common keywords', () => {
expect(hasSqlInjectionPatterns("'; DROP TABLE users; --")).toBe(true)
expect(hasSqlInjectionPatterns('OR 1=1')).toBe(true)
})
it('does not flag normal text', () => {
expect(hasSqlInjectionPatterns('Hello, world')).toBe(false)
})
})
describe('isValidUuid', () => {
it('accepts a v4 UUID', () => {
expect(isValidUuid('11111111-2222-4333-8444-555555555555')).toBe(true)
})
it('rejects garbage', () => {
expect(isValidUuid('hello')).toBe(false)
})
})
describe('truncate', () => {
it('shortens and appends ellipsis', () => {
expect(truncate('hello world', 8)).toBe('hello...')
})
it('returns the original if under the limit', () => {
expect(truncate('hello', 50)).toBe('hello')
})
})
describe('checkRateLimit', () => {
beforeEach(() => {
// Reset the in-memory map between tests by churning fresh identifiers.
})
it('allows requests under the limit', () => {
const id = `t1-${Date.now()}`
expect(checkRateLimit(id, 3, 60000).allowed).toBe(true)
expect(checkRateLimit(id, 3, 60000).allowed).toBe(true)
expect(checkRateLimit(id, 3, 60000).allowed).toBe(true)
})
it('blocks once the limit is hit', () => {
const id = `t2-${Date.now()}`
checkRateLimit(id, 2, 60000)
checkRateLimit(id, 2, 60000)
expect(checkRateLimit(id, 2, 60000).allowed).toBe(false)
})
it('reports remaining count', () => {
const id = `t3-${Date.now()}`
expect(checkRateLimit(id, 3, 60000).remaining).toBe(2)
expect(checkRateLimit(id, 3, 60000).remaining).toBe(1)
})
})
describe('validateContentLength', () => {
it('passes when length is in range', () => {
expect(validateContentLength('hello', 1, 10).valid).toBe(true)
})
it('fails when too short', () => {
expect(validateContentLength('', 1, 10).valid).toBe(false)
})
it('fails when too long', () => {
expect(validateContentLength('x'.repeat(11), 1, 10).valid).toBe(false)
})
})
+327
View File
@@ -0,0 +1,327 @@
import { describe, it, expect } from 'vitest'
import { z } from 'zod'
import {
flexibleDateTime,
createTaskSchema,
taskIdSchema,
createProjectSchema,
updateProjectStatusSchema,
addMemberSchema,
updateMemberSchema,
createContactSchema,
updateContactSchema,
createKbEntrySchema,
updateKbEntrySchema,
createSecretSchema,
logCashPaymentSchema,
linkPaymentSchema,
updateSpaceSettingsSchema,
saveIntegrationSchema,
upsertAreaLeadSchema,
uuidSchema,
} from '@/lib/validations'
const VALID_UUID = '11111111-2222-3333-4444-555555555555'
describe('flexibleDateTime', () => {
const schema = z.object({ when: flexibleDateTime().optional() })
it('accepts a YYYY-MM-DD date-only string from <input type="date">', () => {
const r = schema.safeParse({ when: '2026-05-22' })
expect(r.success).toBe(true)
if (r.success) {
expect(r.data.when).toBe('2026-05-22T00:00:00.000Z')
}
})
it('accepts a YYYY-MM-DDTHH:MM datetime-local string', () => {
const r = schema.safeParse({ when: '2026-05-22T14:30' })
expect(r.success).toBe(true)
if (r.success && r.data.when) {
// JS Date interprets "2026-05-22T14:30" as local time; the helper
// outputs UTC ISO. We just assert it's a valid ISO datetime.
expect(r.data.when).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/)
}
})
it('passes through a full RFC 3339 datetime unchanged', () => {
const iso = '2026-05-22T14:30:00.000Z'
const r = schema.safeParse({ when: iso })
expect(r.success).toBe(true)
if (r.success) expect(r.data.when).toBe(iso)
})
it('maps empty string to null (treats it as unset)', () => {
const r = schema.safeParse({ when: '' })
expect(r.success).toBe(true)
if (r.success) expect(r.data.when).toBeNull()
})
it('maps undefined to undefined / null', () => {
const r = schema.safeParse({})
expect(r.success).toBe(true)
})
it('rejects obvious garbage', () => {
const r = schema.safeParse({ when: 'tomorrow at three' })
expect(r.success).toBe(false)
})
})
describe('createTaskSchema', () => {
it('accepts minimal valid input', () => {
const r = createTaskSchema.safeParse({ title: 'Fix laser cooling' })
expect(r.success).toBe(true)
})
it('rejects empty title', () => {
const r = createTaskSchema.safeParse({ title: '' })
expect(r.success).toBe(false)
})
it('rejects title over 200 chars', () => {
const r = createTaskSchema.safeParse({ title: 'x'.repeat(201) })
expect(r.success).toBe(false)
})
it('accepts every database recurrence value, including biweekly', () => {
for (const recurrence of ['none', 'daily', 'weekly', 'biweekly', 'monthly']) {
const r = createTaskSchema.safeParse({ title: 'x', recurrence })
expect(r.success, `recurrence=${recurrence}`).toBe(true)
}
})
it('accepts due_date as YYYY-MM-DD (the bug fix from pass 9)', () => {
const r = createTaskSchema.safeParse({ title: 'x', due_date: '2026-05-22' })
expect(r.success).toBe(true)
if (r.success) expect(r.data.due_date).toBe('2026-05-22T00:00:00.000Z')
})
it('rejects unknown recurrence values', () => {
const r = createTaskSchema.safeParse({ title: 'x', recurrence: 'fortnightly' })
expect(r.success).toBe(false)
})
it('accepts task and chore as type, rejects other strings', () => {
expect(createTaskSchema.safeParse({ title: 'x', type: 'task' }).success).toBe(true)
expect(createTaskSchema.safeParse({ title: 'x', type: 'chore' }).success).toBe(true)
expect(createTaskSchema.safeParse({ title: 'x', type: 'errand' }).success).toBe(false)
})
})
describe('taskIdSchema', () => {
it('accepts a valid UUID', () => {
expect(taskIdSchema.safeParse(VALID_UUID).success).toBe(true)
})
it('rejects non-UUID strings', () => {
expect(taskIdSchema.safeParse('not-a-uuid').success).toBe(false)
})
})
describe('uuidSchema', () => {
it('accepts a valid UUID', () => {
expect(uuidSchema.safeParse(VALID_UUID).success).toBe(true)
})
it('rejects empty string', () => {
expect(uuidSchema.safeParse('').success).toBe(false)
})
})
describe('createProjectSchema', () => {
it('accepts title only', () => {
expect(createProjectSchema.safeParse({ title: 'Build CNC mount' }).success).toBe(true)
})
it('accepts optional tags', () => {
expect(
createProjectSchema.safeParse({ title: 'x', tags: ['fab', 'urgent'] }).success,
).toBe(true)
})
})
describe('updateProjectStatusSchema', () => {
it('accepts every real project_status enum value', () => {
for (const status of ['backlog', 'in_progress', 'review', 'done', 'blocked']) {
const r = updateProjectStatusSchema.safeParse({ projectId: VALID_UUID, status })
expect(r.success, `status=${status}`).toBe(true)
}
})
it('rejects legacy status names that the database does not have', () => {
for (const status of ['planning', 'active', 'paused', 'completed', 'cancelled']) {
const r = updateProjectStatusSchema.safeParse({ projectId: VALID_UUID, status })
expect(r.success, `status=${status}`).toBe(false)
}
})
it('rejects invalid projectId', () => {
expect(
updateProjectStatusSchema.safeParse({ projectId: 'nope', status: 'done' }).success,
).toBe(false)
})
})
describe('addMemberSchema', () => {
it('accepts the associate role (added in pass 2)', () => {
const r = addMemberSchema.safeParse({
email: 'a@b.com',
display_name: 'Alex',
role: 'associate',
tier: 'basic',
})
expect(r.success).toBe(true)
})
it('rejects unknown role', () => {
const r = addMemberSchema.safeParse({
email: 'a@b.com',
display_name: 'Alex',
role: 'overlord',
tier: 'basic',
})
expect(r.success).toBe(false)
})
it('rejects invalid email', () => {
const r = addMemberSchema.safeParse({
email: 'not-an-email',
display_name: 'Alex',
})
expect(r.success).toBe(false)
})
})
describe('updateMemberSchema', () => {
it('accepts a partial update with just memberId', () => {
expect(
updateMemberSchema.safeParse({ memberId: VALID_UUID }).success,
).toBe(true)
})
it('rejects unknown status', () => {
expect(
updateMemberSchema.safeParse({ memberId: VALID_UUID, status: 'banned' }).success,
).toBe(false)
})
})
describe('createContactSchema', () => {
it('accepts every database contact_type', () => {
for (const t of ['vendor', 'supplier', 'partner', 'landlord', 'city']) {
const r = createContactSchema.safeParse({ name: 'X', contact_type: t })
expect(r.success, `contact_type=${t}`).toBe(true)
}
})
it('rejects legacy contact_type values', () => {
for (const t of ['sponsor', 'media', 'other']) {
const r = createContactSchema.safeParse({ name: 'X', contact_type: t })
expect(r.success, `contact_type=${t}`).toBe(false)
}
})
})
describe('updateContactSchema', () => {
it('requires contactId', () => {
expect(updateContactSchema.safeParse({ name: 'X' }).success).toBe(false)
})
})
describe('createKbEntrySchema', () => {
it('accepts the real visibility enum', () => {
for (const v of ['all_members', 'board', 'admin_only']) {
const r = createKbEntrySchema.safeParse({ title: 't', content: 'c', visibility: v })
expect(r.success, `visibility=${v}`).toBe(true)
}
})
it('rejects legacy visibility values', () => {
for (const v of ['public', 'members', 'admin']) {
const r = createKbEntrySchema.safeParse({ title: 't', content: 'c', visibility: v })
expect(r.success, `visibility=${v}`).toBe(false)
}
})
it('defaults visibility to all_members', () => {
const r = createKbEntrySchema.safeParse({ title: 't', content: 'c' })
expect(r.success).toBe(true)
if (r.success) expect(r.data.visibility).toBe('all_members')
})
})
describe('updateKbEntrySchema', () => {
it('requires entryId', () => {
expect(updateKbEntrySchema.safeParse({ title: 't' }).success).toBe(false)
})
})
describe('createSecretSchema', () => {
it('requires title and value', () => {
expect(createSecretSchema.safeParse({ title: 't', value: 'v' }).success).toBe(true)
expect(createSecretSchema.safeParse({ title: 't' }).success).toBe(false)
expect(createSecretSchema.safeParse({ value: 'v' }).success).toBe(false)
})
})
describe('logCashPaymentSchema', () => {
it('accepts a positive amount and a non-empty note', () => {
const r = logCashPaymentSchema.safeParse({ amount: 25, from_note: 'May dues' })
expect(r.success).toBe(true)
})
it('rejects zero or negative amounts', () => {
expect(logCashPaymentSchema.safeParse({ amount: 0, from_note: 'x' }).success).toBe(false)
expect(logCashPaymentSchema.safeParse({ amount: -5, from_note: 'x' }).success).toBe(false)
})
it('member_id must be a UUID if present', () => {
expect(
logCashPaymentSchema.safeParse({ amount: 1, from_note: 'x', member_id: 'nope' }).success,
).toBe(false)
expect(
logCashPaymentSchema.safeParse({ amount: 1, from_note: 'x', member_id: VALID_UUID }).success,
).toBe(true)
})
})
describe('linkPaymentSchema', () => {
it('requires both UUIDs', () => {
expect(
linkPaymentSchema.safeParse({ paymentId: VALID_UUID, memberId: VALID_UUID }).success,
).toBe(true)
expect(linkPaymentSchema.safeParse({ paymentId: 'x', memberId: VALID_UUID }).success).toBe(false)
})
})
describe('updateSpaceSettingsSchema', () => {
it('accepts a partial update', () => {
expect(updateSpaceSettingsSchema.safeParse({ name: 'New name' }).success).toBe(true)
})
it('enforces slug character set when slug is present', () => {
expect(updateSpaceSettingsSchema.safeParse({ slug: 'good-slug' }).success).toBe(false)
expect(updateSpaceSettingsSchema.safeParse({ slug: 'goodslug' }).success).toBe(true)
})
})
describe('saveIntegrationSchema', () => {
it('requires platform and a config record', () => {
expect(
saveIntegrationSchema.safeParse({ platform: 'paypal', config: { client_id: 'x' } }).success,
).toBe(true)
expect(saveIntegrationSchema.safeParse({ platform: 'paypal' }).success).toBe(false)
})
})
describe('upsertAreaLeadSchema', () => {
it('accepts the real area_lead_status values', () => {
for (const s of ['active', 'vacant', 'handoff']) {
const r = upsertAreaLeadSchema.safeParse({
area_code: 'LASER',
area_name: 'Laser',
status: s,
})
expect(r.success, `status=${s}`).toBe(true)
}
})
it('rejects unknown status', () => {
const r = upsertAreaLeadSchema.safeParse({
area_code: 'LASER',
area_name: 'Laser',
status: 'pending',
})
expect(r.success).toBe(false)
})
})