feat: add founder club settings and governance lifecycle

This commit is contained in:
Developer 2026-09-09 07:51:48 -04:00
parent 5eb47e8c09
commit d544292a3c
9 changed files with 1754 additions and 18 deletions

View File

@ -2850,6 +2850,169 @@ describe('Social Architecture Security Rules', () => {
}));
});
test('FP-14.2: Club Settings access authority and boundary isolation (Admin authority & attack matrix)', async () => {
const alice = testEnv.authenticatedContext('alice').firestore();
const bob = testEnv.authenticatedContext('bob').firestore();
const charlie = testEnv.authenticatedContext('charlie').firestore();
const dave = testEnv.authenticatedContext('dave').firestore();
const eve = testEnv.authenticatedContext('eve').firestore();
await testEnv.withSecurityRulesDisabled(async (context) => {
const db = context.firestore();
await setDoc(doc(db, 'clubs/club_settings_auth'), {
id: 'club_settings_auth',
name: 'Settings Authority MC',
slug: 'settings-authority-mc',
visibility: 'public',
admissionPolicy: 'request',
ownerUid: 'alice',
memberCount: 3,
archivedAt: null,
createdAt: new Date(),
updatedAt: new Date(),
});
// Owner Alice
await setDoc(doc(db, 'clubs/club_settings_auth/members/alice'), {
uid: 'alice',
clubId: 'club_settings_auth',
role: 'owner',
status: 'active',
});
// Active Admin Bob
await setDoc(doc(db, 'clubs/club_settings_auth/members/bob'), {
uid: 'bob',
clubId: 'club_settings_auth',
role: 'admin',
status: 'active',
});
// Active Member Charlie with displayRank 'President'
await setDoc(doc(db, 'clubs/club_settings_auth/members/charlie'), {
uid: 'charlie',
clubId: 'club_settings_auth',
role: 'member',
status: 'active',
displayRank: 'President',
});
// Removed former admin Dave
await setDoc(doc(db, 'clubs/club_settings_auth/members/dave'), {
uid: 'dave',
clubId: 'club_settings_auth',
role: 'admin',
status: 'removed',
});
// Former admin Eve who left
await setDoc(doc(db, 'clubs/club_settings_auth/members/eve'), {
uid: 'eve',
clubId: 'club_settings_auth',
role: 'admin',
status: 'left',
});
});
const clubDocRef = (db) => doc(db, 'clubs/club_settings_auth');
// 1. Active Admin valid visibility update -> ALLOW
await assertSucceeds(updateDoc(clubDocRef(bob), {
visibility: 'unlisted',
updatedAt: new Date(),
}));
await assertSucceeds(updateDoc(clubDocRef(bob), {
visibility: 'private',
updatedAt: new Date(),
}));
// 2. Active Admin valid admissionPolicy update -> ALLOW
await assertSucceeds(updateDoc(clubDocRef(bob), {
admissionPolicy: 'open',
updatedAt: new Date(),
}));
await assertSucceeds(updateDoc(clubDocRef(bob), {
admissionPolicy: 'inviteOnly',
updatedAt: new Date(),
}));
// 3. Admin invalid visibility enum -> DENY
await assertFails(updateDoc(clubDocRef(bob), {
visibility: 'hidden_leak',
updatedAt: new Date(),
}));
// 4. Admin invalid admissionPolicy enum -> DENY
await assertFails(updateDoc(clubDocRef(bob), {
admissionPolicy: 'free_for_all',
updatedAt: new Date(),
}));
// 5. Admin visibility + ownerUid escalation -> DENY
await assertFails(updateDoc(clubDocRef(bob), {
visibility: 'private',
ownerUid: 'bob',
updatedAt: new Date(),
}));
// 6. Admin admission + archivedAt mutation -> DENY
await assertFails(updateDoc(clubDocRef(bob), {
admissionPolicy: 'inviteOnly',
archivedAt: new Date(),
updatedAt: new Date(),
}));
// 7. Admin visibility + slug mutation -> DENY
await assertFails(updateDoc(clubDocRef(bob), {
visibility: 'unlisted',
slug: 'hacked-handle',
updatedAt: new Date(),
}));
// 8. Admin visibility + memberCount mutation -> DENY
await assertFails(updateDoc(clubDocRef(bob), {
visibility: 'unlisted',
memberCount: 100,
updatedAt: new Date(),
}));
// 9. Regular Member visibility/admission mutation -> DENY
await assertFails(updateDoc(clubDocRef(charlie), {
visibility: 'public',
updatedAt: new Date(),
}));
await assertFails(updateDoc(clubDocRef(charlie), {
admissionPolicy: 'open',
updatedAt: new Date(),
}));
// 10. Member with displayRank President mutation -> DENY (displayRank grants 0 authority)
await assertFails(updateDoc(clubDocRef(charlie), {
visibility: 'unlisted',
updatedAt: new Date(),
}));
// 11. Inactive/former Admin mutation -> DENY
await assertFails(updateDoc(clubDocRef(dave), {
visibility: 'unlisted',
updatedAt: new Date(),
}));
await assertFails(updateDoc(clubDocRef(eve), {
admissionPolicy: 'open',
updatedAt: new Date(),
}));
// 12. Owner valid updates and enum validation
await assertSucceeds(updateDoc(clubDocRef(alice), {
visibility: 'public',
admissionPolicy: 'request',
updatedAt: new Date(),
}));
await assertFails(updateDoc(clubDocRef(alice), {
visibility: 'invalid_vis',
updatedAt: new Date(),
}));
await assertFails(updateDoc(clubDocRef(alice), {
admissionPolicy: 'invalid_policy',
updatedAt: new Date(),
}));
});
describe('CLUB-2C.2: Membership Re-Entry Attack Matrix (Cases A - AF)', () => {
async function setupClub(db, clubId, admissionPolicy, archived = false) {
await setDoc(doc(db, `clubs/${clubId}`), {

View File

@ -777,15 +777,19 @@ service cloud.firestore {
'archivedAt', 'updatedAt'
])
&& (!('name' in request.resource.data) || (request.resource.data.name is string && request.resource.data.name.size() >= 3 && request.resource.data.name.size() <= 50))
&& (!('visibility' in request.resource.data) || request.resource.data.visibility in ['public', 'unlisted', 'private'])
&& (!('admissionPolicy' in request.resource.data) || request.resource.data.admissionPolicy in ['open', 'request', 'inviteOnly'])
) ||
// Case 3: Admin editing metadata (cannot change ownerUid, slug, createdAt, archivedAt, visibility, admissionPolicy, memberCount)
// Case 3: Admin editing metadata and access settings (cannot change ownerUid, slug, createdAt, archivedAt, memberCount)
(
isClubAdmin(request.auth.uid)
&& request.resource.data.diff(resource.data).affectedKeys().hasOnly([
'name', 'description', 'logoUrl', 'bannerUrl',
'homeRegion', 'motorcycleFocus', 'updatedAt'
'name', 'description', 'visibility', 'admissionPolicy',
'logoUrl', 'bannerUrl', 'homeRegion', 'motorcycleFocus', 'updatedAt'
])
&& (!('name' in request.resource.data) || (request.resource.data.name is string && request.resource.data.name.size() >= 3 && request.resource.data.name.size() <= 50))
&& (!('visibility' in request.resource.data) || request.resource.data.visibility in ['public', 'unlisted', 'private'])
&& (!('admissionPolicy' in request.resource.data) || request.resource.data.admissionPolicy in ['open', 'request', 'inviteOnly'])
) ||
// Case 4: Member count increment/decrement by membership transitions ONLY
(

View File

@ -289,14 +289,6 @@ class ClubService {
);
}
// Only owner can alter visibility or admission policy
if ((visibility != null || admissionPolicy != null) && !isOwner) {
throw const ClubOperationException(
'Only the club owner can change visibility or admission policy.',
code: 'permission-denied',
);
}
final updates = <String, dynamic>{
'updatedAt': FieldValue.serverTimestamp(),
};

View File

@ -0,0 +1,158 @@
'use client';
import React, { useEffect, useState, use } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import {
fetchFounderClubContext,
FounderClubContext,
sanitizePortalError,
} from '../../../../../lib/club-data';
import { FounderPortalShell } from '../../../../../components/founder-portal/founder-portal-shell';
import { FounderSettingsEditor } from '../../../../../components/founder-portal/founder-settings-editor';
import { OverviewSkeleton } from '../../../../../components/founder-portal/skeleton-state';
import { SafeErrorState } from '../../../../../components/founder-portal/safe-error-state';
import { firebaseAuth } from '../../../../../lib/firebase-client';
interface PageProps {
params: Promise<{ clubId: string }>;
}
export default function ClubFounderSettingsPage({ params }: PageProps) {
const { clubId } = use(params);
const router = useRouter();
const searchParams = useSearchParams();
const [context, setContext] = useState<FounderClubContext | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [unauthenticated, setUnauthenticated] = useState(false);
const [unauthorized, setUnauthorized] = useState(false);
async function loadData() {
setLoading(true);
setError(null);
setUnauthenticated(false);
setUnauthorized(false);
try {
await firebaseAuth.authStateReady();
const currentUser = firebaseAuth.currentUser;
const isDemoPreview = searchParams?.get('preview') === 'demo';
// Unauthenticated Guard: Direct access without demo preview requires authentication
if (!isDemoPreview && !currentUser) {
setUnauthenticated(true);
setLoading(false);
return;
}
const ctx = await fetchFounderClubContext(clubId, currentUser?.uid, {
allowDemoPreview: isDemoPreview,
});
// Authorization Guard: Owner and Admin can access Settings; Members cannot
if (!isDemoPreview && !ctx.permissions.canManageSettings) {
setUnauthorized(true);
setLoading(false);
return;
}
setContext(ctx);
} catch (err) {
setError(sanitizePortalError(err, "Club settings couldn't be loaded."));
} finally {
setLoading(false);
}
}
useEffect(() => {
void loadData();
}, [clubId]);
if (loading) {
return (
<div className="min-h-screen bg-surface p-6">
<OverviewSkeleton />
</div>
);
}
// Unauthenticated safe error state
if (unauthenticated) {
return (
<div className="min-h-screen bg-surface flex items-center justify-center p-6">
<SafeErrorState
title="Authentication Required"
message="Sign in with your rider account to access club settings."
actionLabel="Sign In"
onAction={() => router.push('/login')}
/>
</div>
);
}
// Unauthorized (Member or non-admin) safe error state
if (unauthorized) {
return (
<div className="min-h-screen bg-surface flex items-center justify-center p-6">
<SafeErrorState
title="Management Access Restricted"
message="You must be an Owner or Admin of this Club to access Club Settings."
actionLabel="Return to Clubs"
onAction={() => router.push('/clubs')}
/>
</div>
);
}
// Error or missing context
if (error || !context) {
return (
<div className="min-h-screen bg-surface flex items-center justify-center p-6">
<SafeErrorState
title="Settings Unavailable"
message={error ?? "Club settings couldn't be loaded."}
actionLabel="Return to Clubs"
onAction={() => router.push('/clubs')}
onRetry={loadData}
/>
</div>
);
}
return (
<FounderPortalShell
club={context.club}
permissions={context.permissions}
activeNav="settings"
pendingApplicationsCount={4}
upcomingRidesCount={2}
>
<FounderSettingsEditor
club={context.club}
permissions={context.permissions}
callerUid={context.currentUser.uid}
isDemoFixture={context.isDemoFixture}
onClubUpdated={(updated) => {
setContext((prev) => {
if (!prev) return null;
const isFormerOwner = updated.ownerUid && updated.ownerUid !== prev.club.ownerUid;
return {
...prev,
club: {
...prev.club,
...updated,
},
currentUser: isFormerOwner
? { ...prev.currentUser, role: 'admin', displayRank: 'Admin' }
: prev.currentUser,
permissions: isFormerOwner
? { ...prev.permissions, role: 'admin', canTransferOwnership: false }
: prev.permissions,
};
});
}}
/>
</FounderPortalShell>
);
}

File diff suppressed because it is too large Load Diff

View File

@ -74,6 +74,7 @@ export function FounderSidebar({
function getRouteHref(id: string) {
if (id === 'overview') return `/clubs/${clubParam}/manage${queryParam}`;
if (id === 'profile') return `/clubs/${clubParam}/manage/profile${queryParam}`;
if (id === 'settings') return `/clubs/${clubParam}/manage/settings${queryParam}`;
return undefined;
}
@ -86,19 +87,21 @@ export function FounderSidebar({
const isActive = activeNav === item.id;
const href = getRouteHref(item.id);
const isProfile = item.id === 'profile';
const isSettings = item.id === 'settings';
const isSubRoute = isProfile || isSettings;
const content = (
<>
<div className="flex items-center gap-2.5">
<span
className={`material-symbols-outlined text-[18px] ${
isActive && isProfile ? 'text-secondary' : ''
isActive && isSubRoute ? 'text-secondary' : ''
}`}
style={isActive && isProfile ? { fontVariationSettings: "'FILL' 1" } : undefined}
style={isActive && isSubRoute ? { fontVariationSettings: "'FILL' 1" } : undefined}
>
{item.icon}
</span>
<span className={isActive && isProfile ? 'text-secondary font-semibold' : ''}>
<span className={isActive && isSubRoute ? 'text-secondary font-semibold' : ''}>
{item.label}
</span>
</div>
@ -117,7 +120,7 @@ export function FounderSidebar({
{item.badge}
</span>
)}
{isActive && isProfile && !item.badge && (
{isActive && isSubRoute && !item.badge && (
<span className="w-1.5 h-1.5 rounded-full bg-secondary" />
)}
</>
@ -125,7 +128,7 @@ export function FounderSidebar({
const baseClasses = `w-full flex items-center justify-between px-3 py-2 rounded transition-colors text-xs ${
isActive
? isProfile
? isSubRoute
? 'bg-surface-container-high text-primary-container border-l-2 border-primary-container font-medium'
: 'bg-primary text-white font-medium'
: 'text-on-surface-variant hover:bg-surface-container hover:text-on-surface font-normal'

View File

@ -55,6 +55,12 @@ export function FounderTopBar({
<span className="text-secondary font-semibold uppercase">CLUB PROFILE</span>
</>
)}
{activeNav === 'settings' && (
<>
<span className="text-outline-variant">/</span>
<span className="text-secondary font-semibold uppercase">SETTINGS</span>
</>
)}
</div>
<div className="relative flex items-center shrink-0">

View File

@ -1,4 +1,4 @@
import { collection, doc, getDoc, getDocs, query, where, limit, collectionGroup, updateDoc, serverTimestamp } from 'firebase/firestore';
import { collection, doc, getDoc, getDocs, query, where, limit, collectionGroup, updateDoc, serverTimestamp, runTransaction } from 'firebase/firestore';
import { firestore } from './firebase-client';
import {
Club,
@ -8,6 +8,8 @@ import {
UserPortalPermissions,
ClubActivityItem,
ClubRole,
ClubVisibility,
ClubAdmissionPolicy,
} from '../components/founder-portal/types';
import {
FIXTURE_FOUNDER_CLUB,
@ -665,3 +667,239 @@ export async function updateClubProfile(
});
}
export interface UpdateClubAccessSettingsInput {
visibility: ClubVisibility;
admissionPolicy: ClubAdmissionPolicy;
}
/**
* Whitelist update of club access controls (visibility and admissionPolicy).
* Authorizes active Owner and active Admin.
* Forbids mutating slug, ownerUid, memberCount, archivedAt, name, description, etc.
*/
export async function updateClubAccessSettings(
clubId: string,
callerUid: string,
input: UpdateClubAccessSettingsInput
): Promise<void> {
if (!callerUid) {
throw new Error('Authentication required.');
}
const validVisibilities: ClubVisibility[] = ['public', 'unlisted', 'private'];
const validAdmissions: ClubAdmissionPolicy[] = ['open', 'request', 'inviteOnly'];
if (!validVisibilities.includes(input.visibility)) {
throw new Error('Invalid club visibility setting.');
}
if (!validAdmissions.includes(input.admissionPolicy)) {
throw new Error('Invalid club admission policy setting.');
}
const clubRef = doc(firestore, 'clubs', clubId);
const clubSnap = await getDoc(clubRef);
if (!clubSnap.exists()) {
throw new Error('Club not found.');
}
const cd = clubSnap.data();
if (cd.archivedAt) {
throw new Error('Archived clubs cannot be modified.');
}
let isAuthorized = cd.ownerUid === callerUid;
if (!isAuthorized) {
try {
const memberSnap = await getDoc(doc(firestore, 'clubs', clubId, 'members', callerUid));
if (memberSnap.exists()) {
const m = memberSnap.data();
if (m.status === 'active' && (m.role === 'owner' || m.role === 'admin')) {
isAuthorized = true;
}
}
} catch {
// Unauthorized
}
}
if (!isAuthorized) {
throw new Error('Permission denied. Only club owners and administrators can modify club access settings.');
}
await updateDoc(clubRef, {
visibility: input.visibility,
admissionPolicy: input.admissionPolicy,
updatedAt: serverTimestamp(),
});
}
/**
* Bounded query for active members eligible to receive club ownership.
* Excludes the current owner.
* Never performs N+1 profile queries; uses only legally readable member document fields.
*/
export async function fetchEligibleTransferMembers(
clubId: string,
callerUid: string,
options?: { allowDemoPreview?: boolean }
): Promise<ClubMember[]> {
if (options?.allowDemoPreview && process.env.NODE_ENV === 'development') {
return [
{
uid: 'demo-rider-02',
clubId,
status: 'active',
role: 'admin',
displayRank: 'Vice President',
updatedAt: new Date().toISOString(),
displayName: 'Elena Rostova',
handle: '@elena_rides',
},
{
uid: 'demo-rider-03',
clubId,
status: 'active',
role: 'member',
displayRank: 'Road Captain',
updatedAt: new Date().toISOString(),
displayName: 'Dave Miller',
handle: '@dave_m',
},
];
}
if (!callerUid) {
throw new Error('Authentication required.');
}
const membersRef = collection(firestore, 'clubs', clubId, 'members');
const q = query(
membersRef,
where('status', '==', 'active'),
limit(25)
);
const snap = await getDocs(q);
const eligible: ClubMember[] = [];
for (const d of snap.docs) {
if (d.id === callerUid) continue; // Exclude current owner
const data = d.data();
eligible.push({
uid: d.id,
clubId,
status: 'active',
role: (data.role as ClubRole) || 'member',
displayRank: data.displayRank || null,
joinedAt: data.joinedAt?.toDate?.() ? data.joinedAt.toDate().toISOString() : (data.joinedAt || null),
updatedAt: data.updatedAt?.toDate?.() ? data.updatedAt.toDate().toISOString() : (data.updatedAt || new Date().toISOString()),
displayName: data.displayName || null,
handle: data.handle ? `@${data.handle}` : null,
});
}
return eligible;
}
/**
* Atomically transfers club ownership from current owner to an active member.
* Strictly verifies caller is current owner and target is active member.
* Caller is demoted to admin; target is promoted to owner.
* Preserves atomic rules contract (Case 1).
*/
export async function transferClubOwnership(
clubId: string,
currentOwnerUid: string,
newOwnerUid: string
): Promise<void> {
if (!currentOwnerUid) {
throw new Error('Authentication required.');
}
if (!newOwnerUid || currentOwnerUid === newOwnerUid) {
throw new Error('Invalid ownership transfer target.');
}
const clubRef = doc(firestore, 'clubs', clubId);
const currentOwnerMemberRef = doc(firestore, 'clubs', clubId, 'members', currentOwnerUid);
const newOwnerMemberRef = doc(firestore, 'clubs', clubId, 'members', newOwnerUid);
await runTransaction(firestore, async (tx) => {
const clubSnap = await tx.get(clubRef);
if (!clubSnap.exists()) {
throw new Error('Club not found.');
}
const cd = clubSnap.data();
if (cd.archivedAt) {
throw new Error('Archived clubs cannot transfer ownership.');
}
if (cd.ownerUid !== currentOwnerUid) {
throw new Error('Only the club owner can transfer ownership.');
}
const newOwnerSnap = await tx.get(newOwnerMemberRef);
if (!newOwnerSnap.exists()) {
throw new Error('Target member does not exist in this club.');
}
const md = newOwnerSnap.data();
if (md.status !== 'active') {
throw new Error('Target rider must be an active club member to receive ownership.');
}
const now = serverTimestamp();
// 1. Update club root ownerUid
tx.update(clubRef, {
ownerUid: newOwnerUid,
updatedAt: now,
});
// 2. Demote current owner to admin
tx.update(currentOwnerMemberRef, {
role: 'admin',
updatedAt: now,
});
// 3. Promote new owner to owner role
tx.update(newOwnerMemberRef, {
role: 'owner',
updatedAt: now,
});
});
}
/**
* Soft-deletes/archives a Club. Only permitted by the Owner.
* Removes club from discovery queries while preserving all historical records.
* Club deletion is NOT supported.
*/
export async function archiveClub(
clubId: string,
callerUid: string
): Promise<void> {
if (!callerUid) {
throw new Error('Authentication required.');
}
const clubRef = doc(firestore, 'clubs', clubId);
const clubSnap = await getDoc(clubRef);
if (!clubSnap.exists()) {
throw new Error('Club not found.');
}
const cd = clubSnap.data();
if (cd.archivedAt) {
throw new Error('Club is already archived.');
}
if (cd.ownerUid !== callerUid) {
throw new Error('Permission denied. Only the club owner can archive the club.');
}
const now = serverTimestamp();
await updateDoc(clubRef, {
archivedAt: now,
updatedAt: now,
});
}

View File

@ -33,6 +33,7 @@ function loadTsModule(relativePath) {
collectionGroup: () => ({}),
updateDoc: async () => {},
serverTimestamp: () => new Date(),
runTransaction: async (_db, cb) => cb({ get: async () => ({ exists: () => false }), update: () => {} }),
};
}
if (specifier.startsWith('.')) {
@ -61,6 +62,10 @@ const {
fetchManageableClubs,
fetchFounderClubContext,
updateClubProfile,
updateClubAccessSettings,
fetchEligibleTransferMembers,
transferClubOwnership,
archiveClub,
} = clubData;
const { FIXTURE_FOUNDER_CLUB, FIXTURE_METRICS } = clubFixtures;
@ -568,7 +573,150 @@ async function runAllTests() {
console.log(' PASS: FP-13.1 Tests A through J verified (Footer removed, verified badge removed, stage terms removed, internal ID removed, media labels removed, preview identity-only, non-editable visibility/admission, O(1) reads, whitelist mutation, fixture safety).');
console.log('--- ALL FOUNDER PORTAL TESTS PASSED (21/21) ---');
// =========================================================================
// TEST 22: FP-14 Club Settings Implementation Suite (Tests A through X)
// =========================================================================
console.log('Testing: FP-14 Settings Suite (Tests A - X)...');
const settingsPageSrc = fs.readFileSync(path.resolve(__dirname, '../app/clubs/[clubId]/manage/settings/page.tsx'), 'utf8');
const settingsEditorSrc = fs.readFileSync(path.resolve(__dirname, '../components/founder-portal/founder-settings-editor.tsx'), 'utf8');
// A. Settings auth guard
assert(settingsPageSrc.includes('firebaseAuth.currentUser'), 'Settings page must check firebaseAuth.currentUser');
assert(settingsPageSrc.includes('setUnauthenticated(true)'), 'Settings page must set unauthenticated on missing user');
assert(settingsPageSrc.includes('/login'), 'Settings page must direct unauthenticated users to /login');
// B. Owner/Admin page authorization
assert(settingsPageSrc.includes('ctx.permissions.canManageSettings'), 'Settings page must check canManageSettings');
const ownerPerms = getPortalPermissions('owner');
const adminPerms = getPortalPermissions('admin');
assert.equal(ownerPerms.canManageSettings, true, 'Owner must be authorized to manage settings');
assert.equal(adminPerms.canManageSettings, true, 'Admin must be authorized to access settings page');
// C. Member/non-member denial
const memberPerms = getPortalPermissions('member');
const unauthPerms = getPortalPermissions('member', false);
assert.equal(memberPerms.canManageSettings, false, 'Member must be denied settings management access');
assert.equal(unauthPerms.canManageSettings, false, 'Unauthenticated caller must be denied settings access');
assert(settingsPageSrc.includes('Management Access Restricted'), 'Settings page must show restricted state for members');
// D. visibility enum exactly public/unlisted/private
const updateAccessBody = clubDataSrc.substring(clubDataSrc.indexOf('function updateClubAccessSettings'), clubDataSrc.indexOf('function fetchEligibleTransferMembers'));
assert(updateAccessBody.includes("'public', 'unlisted', 'private'"), 'updateClubAccessSettings must accept only public, unlisted, private');
// E. admission enum exactly open/request/inviteOnly
assert(updateAccessBody.includes("'open', 'request', 'inviteOnly'"), 'updateClubAccessSettings must accept only open, request, inviteOnly');
// F. access mutation whitelist
assert(updateAccessBody.includes('updateDoc(clubRef, {'), 'updateClubAccessSettings must call updateDoc');
assert(updateAccessBody.includes('visibility: input.visibility,'), 'updateClubAccessSettings must write visibility');
assert(updateAccessBody.includes('admissionPolicy: input.admissionPolicy,'), 'updateClubAccessSettings must write admissionPolicy');
assert(updateAccessBody.includes('updatedAt: serverTimestamp(),'), 'updateClubAccessSettings must write updatedAt');
assert(updateAccessBody.includes("m.role === 'owner' || m.role === 'admin'"), 'updateClubAccessSettings must authorize active owner and active admin');
assert(updateAccessBody.includes("m.status === 'active'"), 'updateClubAccessSettings must verify active membership status');
assert(!updateAccessBody.includes('slug:'), 'updateClubAccessSettings MUST NOT write slug');
assert(!updateAccessBody.includes('ownerUid:'), 'updateClubAccessSettings MUST NOT write ownerUid');
assert(!updateAccessBody.includes('memberCount:'), 'updateClubAccessSettings MUST NOT write memberCount');
assert(!updateAccessBody.includes('archivedAt:'), 'updateClubAccessSettings MUST NOT write archivedAt');
assert(!updateAccessBody.includes('name:'), 'updateClubAccessSettings MUST NOT write name');
assert(!updateAccessBody.includes('description:'), 'updateClubAccessSettings MUST NOT write description');
// G. slug immutable
assert(settingsEditorSrc.includes('IMMUTABLE RESERVATION'), 'Settings editor must show immutable reservation badge for slug');
assert(settingsEditorSrc.includes('Club handles are permanently reserved and cannot be renamed or reassigned.'), 'Settings editor must contain canonical immutable handle copy');
assert(!settingsEditorSrc.includes('name="slug"'), 'Settings editor must not have slug input');
// H. rank does not escalate permissions
assert.equal(memberPerms.canTransferOwnership, false, 'Rank does not grant transfer ownership');
assert.equal(memberPerms.canManageSettings, false, 'Rank does not grant manage settings');
assert(settingsEditorSrc.includes('canManageAccess'), 'Settings editor must allow access settings management for owner and admin');
assert(settingsEditorSrc.includes('!canManageAccess'), 'Settings editor must disable access settings for non-leadership');
// I. owner-only ownership transfer
assert.equal(ownerPerms.canTransferOwnership, true, 'Owner can transfer ownership');
assert.equal(adminPerms.canTransferOwnership, false, 'Admin cannot transfer ownership');
const transferBody = clubDataSrc.substring(clubDataSrc.indexOf('function transferClubOwnership'), clubDataSrc.indexOf('function archiveClub'));
assert(transferBody.includes('cd.ownerUid !== currentOwnerUid'), 'transferClubOwnership must check caller is owner');
// J. transfer target must be active member
assert(transferBody.includes("md.status !== 'active'"), 'transferClubOwnership must verify target is active member');
// K. ownership transfer role transition semantics
assert(transferBody.includes("ownerUid: newOwnerUid"), 'transferClubOwnership must update club root ownerUid to newOwnerUid');
assert(transferBody.includes("role: 'admin'"), 'transferClubOwnership must demote current owner to admin');
assert(transferBody.includes("role: 'owner'"), 'transferClubOwnership must promote new owner to owner');
// L. admin cannot transfer ownership
assert(settingsEditorSrc.includes('Transfer Ownership...'), 'Settings editor must include Transfer Ownership trigger');
assert(settingsEditorSrc.includes('!isOwner'), 'Transfer trigger must be disabled for non-owners');
// M. owner-only archive
assert.equal(ownerPerms.role, 'owner', 'Owner role is owner');
assert.equal(adminPerms.role, 'admin', 'Admin role is admin');
assert.equal(ownerPerms.canTransferOwnership, true, 'Owner has exclusive owner transfer authority');
assert.equal(adminPerms.canTransferOwnership, false, 'Admin lacks owner authority');
const archiveBody = clubDataSrc.substring(clubDataSrc.indexOf('function archiveClub'));
assert(archiveBody.includes('cd.ownerUid !== callerUid'), 'archiveClub must verify caller is owner');
// N. admin cannot archive
assert(archiveBody.includes('Permission denied. Only the club owner can archive the club.'), 'archiveClub must throw on non-owner');
// O. archive is not delete
assert(archiveBody.includes('archivedAt: now,'), 'archiveClub must set archivedAt');
assert(!archiveBody.includes('deleteDoc('), 'archiveClub MUST NOT delete document');
// P. no unarchive implementation
assert(!clubDataSrc.includes('unarchiveClub'), 'Must NOT implement unarchiveClub');
assert(!settingsEditorSrc.includes('Unarchive'), 'Settings editor MUST NOT have Unarchive control');
// Q. no personal telemetry/account/billing data
assert(!settingsEditorSrc.includes('leanAngle'), 'Settings editor must not reference leanAngle');
assert(!settingsEditorSrc.includes('speedMph'), 'Settings editor must not reference speedMph');
assert(!settingsEditorSrc.includes('gForce'), 'Settings editor must not reference gForce');
assert(!settingsEditorSrc.includes('stripe'), 'Settings editor must not reference billing');
// R. production fixture isolation
let transferProdThrew = false;
try {
process.env.NODE_ENV = 'production';
await fetchEligibleTransferMembers('test-club', '');
} catch {
transferProdThrew = true;
} finally {
process.env.NODE_ENV = 'test';
}
assert.equal(transferProdThrew, true, 'fetchEligibleTransferMembers must not return mock in production without auth');
// S. sanitized errors
assert(settingsEditorSrc.includes('sanitizePortalError'), 'Settings editor must use sanitizePortalError');
// T. Settings route/sub-navigation state
assert(sidebarSrc.includes("if (id === 'settings') return `/clubs/${clubParam}/manage/settings${queryParam}`;"), 'FounderSidebar must route to settings');
assert(settingsPageSrc.includes('activeNav="settings"'), 'Settings page must pass activeNav="settings"');
assert(settingsPageSrc.includes('<FounderPortalShell'), 'Settings page must reuse FounderPortalShell');
// U. Profile fields are not editable from Settings
assert(!settingsEditorSrc.includes('name="name"'), 'Settings editor must not have name field input');
assert(!settingsEditorSrc.includes('name="description"'), 'Settings editor must not have description input');
assert(!settingsEditorSrc.includes('name="homeRegion"'), 'Settings editor must not have homeRegion input');
assert(!settingsEditorSrc.includes('name="motorcycleFocus"'), 'Settings editor must not have motorcycleFocus input');
// V. no hard delete
assert(!settingsEditorSrc.includes('deleteDoc'), 'Settings editor must NOT call deleteDoc');
assert(!clubDataSrc.includes('deleteDoc('), 'club-data.ts must NOT call deleteDoc');
// W. bounded ownership-target query
const eligibleBody = clubDataSrc.substring(clubDataSrc.indexOf('function fetchEligibleTransferMembers'), clubDataSrc.indexOf('function transferClubOwnership'));
assert(eligibleBody.includes('limit(25)'), 'fetchEligibleTransferMembers must bound query with limit(25)');
assert(eligibleBody.includes("where('status', '==', 'active')"), 'fetchEligibleTransferMembers must query only active members');
// X. no N+1 profile query architecture
assert(!eligibleBody.includes('public_profiles'), 'fetchEligibleTransferMembers must NOT query public_profiles');
assert(!eligibleBody.includes('getDoc('), 'fetchEligibleTransferMembers must NOT call getDoc in loop');
console.log(' PASS: FP-14 Tests A through X verified (Settings auth guard, Owner/Admin authorization, Member denial, visibility enum, admission enum, access mutation whitelist, immutable slug, rank limits, owner-only transfer, active target, atomic role transition, admin transfer restriction, owner-only archive, admin archive restriction, archive is not delete, no unarchive, no telemetry/billing, fixture safety, sanitized errors, sub-routing, profile separation, no hard delete, bounded query, zero N+1 reads).');
console.log('--- ALL FOUNDER PORTAL TESTS PASSED (22/22) ---');
}
runAllTests().catch((err) => {
@ -577,3 +725,4 @@ runAllTests().catch((err) => {
});