From dc96d6f46a718d316366955a22af7129ed40c1ad Mon Sep 17 00:00:00 2001 From: Moheeb Zara Date: Sat, 16 May 2026 22:29:58 -0700 Subject: [PATCH] test: audit-driven coverage for security-relevant pure logic +16 tests (432 -> 448): permissions-catalog least-privilege guard (non-board roles carry no high-risk capability; member/associate are read-only); new invite-logic suite (exact-match creator role, non-admin grants nothing, invalid/mis-cased targets); door-logic SSRF host-spoof edges (userinfo-spoofed host rejected, case-insensitive pin match, trailing-dot FQDN fails closed) + redactDoorSecrets regex-special password and end-of-string e= value; classes pickPromotion unlimited- capacity branch + equal-timestamp tie-break. --- __tests__/classes-logic.test.ts | 18 ++++++++++++ __tests__/door-logic.test.ts | 20 +++++++++++++ __tests__/invite-logic.test.ts | 42 +++++++++++++++++++++++++++ __tests__/permissions-catalog.test.ts | 24 +++++++++++++++ 4 files changed, 104 insertions(+) create mode 100644 __tests__/invite-logic.test.ts diff --git a/__tests__/classes-logic.test.ts b/__tests__/classes-logic.test.ts index 1149ddc..c38d603 100644 --- a/__tests__/classes-logic.test.ts +++ b/__tests__/classes-logic.test.ts @@ -90,3 +90,21 @@ describe('signupFormEligibility', () => { expect(signupFormEligibility({ requiresForm: true, memberHasForm: false, managerOverride: true }).ok).toBe(true) }) }) + +describe('pickPromotion — unlimited capacity + tie-break', () => { + it('promotes the earliest waitlisted when capacity is null (unlimited)', () => { + const s = [ + { id: 'r1', status: 'registered', signed_up_at: '2026-01-01T00:00:00Z' }, + { id: 'w2', status: 'waitlisted', signed_up_at: '2026-01-03T00:00:00Z' }, + { id: 'w1', status: 'waitlisted', signed_up_at: '2026-01-02T00:00:00Z' }, + ] + expect(pickPromotion(s, null)).toBe('w1') + }) + it('is deterministic on equal signed_up_at (stable earliest by list order)', () => { + const s = [ + { id: 'wA', status: 'waitlisted', signed_up_at: '2026-01-02T00:00:00Z' }, + { id: 'wB', status: 'waitlisted', signed_up_at: '2026-01-02T00:00:00Z' }, + ] + expect(pickPromotion(s, 5)).toBe('wA') + }) +}) diff --git a/__tests__/door-logic.test.ts b/__tests__/door-logic.test.ts index d7e0fbc..318e5cb 100644 --- a/__tests__/door-logic.test.ts +++ b/__tests__/door-logic.test.ts @@ -111,4 +111,24 @@ describe('redactDoorSecrets', () => { it('leaves unrelated text intact', () => { expect(redactDoorSecrets('status 200 ok', null)).toBe('status 200 ok') }) + it('redacts a regex-special password literally (split/join, not regex)', () => { + expect(redactDoorSecrets('controller said a.*b ok', 'a.*b')).toBe('controller said ok') + }) + it('redacts an e= value with no trailing delimiter (end of string)', () => { + expect(redactDoorSecrets('GET /?o1&e=1234')).toBe('GET /?o1&e=') + }) +}) + +describe('validateDoorTarget — SSRF host-spoof edges', () => { + it('rejects a userinfo-spoofed host (real host is what after @)', () => { + // new URL(...).hostname is "evil.com", not the pinned LAN IP -> reject + const r = validateDoorTarget('http://192.168.1.50@evil.com/?o1', '192.168.1.50') + expect(r.ok).toBe(false) + }) + it('matches the pin case-insensitively for hostnames', () => { + expect(validateDoorTarget('http://Door.Local/?9', 'door.local').ok).toBe(true) + }) + it('a trailing-dot FQDN does not equal the bare pin (fails closed)', () => { + expect(validateDoorTarget('http://door.local./?9', 'door.local').ok).toBe(false) + }) }) diff --git a/__tests__/invite-logic.test.ts b/__tests__/invite-logic.test.ts new file mode 100644 index 0000000..a9e1507 --- /dev/null +++ b/__tests__/invite-logic.test.ts @@ -0,0 +1,42 @@ +import { describe, it, expect } from 'vitest' +import { canAssignInviteRole, isInviteRole, INVITE_ROLES } from '@/lib/invite-logic' + +describe('isInviteRole', () => { + it('accepts exactly the known invite roles', () => { + for (const r of INVITE_ROLES) expect(isInviteRole(r)).toBe(true) + }) + it('rejects unknown / mis-cased / padded values', () => { + expect(isInviteRole('Admin')).toBe(false) + expect(isInviteRole(' board ')).toBe(false) + expect(isInviteRole('superuser')).toBe(false) + expect(isInviteRole('')).toBe(false) + }) +}) + +describe('canAssignInviteRole', () => { + it('admin may assign any valid role', () => { + for (const r of INVITE_ROLES) expect(canAssignInviteRole('admin', r)).toBe(true) + }) + it('admin cannot assign an invalid target', () => { + expect(canAssignInviteRole('admin', 'superuser')).toBe(false) + }) + it('board may assign anything except admin', () => { + expect(canAssignInviteRole('board', 'admin')).toBe(false) + expect(canAssignInviteRole('board', 'board')).toBe(true) + expect(canAssignInviteRole('board', 'treasurer')).toBe(true) + expect(canAssignInviteRole('board', 'member')).toBe(true) + expect(canAssignInviteRole('board', 'associate')).toBe(true) + }) + it('treasurer / member / associate may assign nothing', () => { + for (const creator of ['treasurer', 'member', 'associate', '']) { + for (const target of INVITE_ROLES) { + expect(canAssignInviteRole(creator, target)).toBe(false) + } + } + }) + it('creatorRole match is exact (no case/whitespace escalation)', () => { + expect(canAssignInviteRole('Admin', 'member')).toBe(false) + expect(canAssignInviteRole(' admin', 'member')).toBe(false) + expect(canAssignInviteRole('BOARD', 'member')).toBe(false) + }) +}) diff --git a/__tests__/permissions-catalog.test.ts b/__tests__/permissions-catalog.test.ts index 6efea4e..177f6c6 100644 --- a/__tests__/permissions-catalog.test.ts +++ b/__tests__/permissions-catalog.test.ts @@ -37,4 +37,28 @@ describe('permissions catalog', () => { it('does not grant admin defaults (admin is implicit and cannot be locked out)', () => { expect(DEFAULT_ROLE_PERMISSIONS.admin).toBeUndefined() }) + + // Least-privilege guard: a future catalog edit must not silently hand + // non-board roles a high-risk capability. (treasurer legitimately has + // payments.manage; member/associate should be read-only.) + it('keeps high-risk capabilities off non-board default roles', () => { + const HIGH_RISK = [ + 'door.manage', 'door.operate', 'members.manage', 'governance.manage', + 'settings.manage', 'customize.manage', 'forms.manage', + 'certifications.grant', 'ops.secrets.read', 'ops.secrets.write', + 'forum.moderate', + ] + for (const role of ['treasurer', 'member', 'associate'] as const) { + const perms = DEFAULT_ROLE_PERMISSIONS[role] ?? [] + expect(perms.filter(p => HIGH_RISK.includes(p))).toEqual([]) + } + }) + + it('member and associate defaults are read-only', () => { + for (const role of ['member', 'associate'] as const) { + for (const p of DEFAULT_ROLE_PERMISSIONS[role] ?? []) { + expect(p.endsWith('.read')).toBe(true) + } + } + }) })