feat: add founder club settings and governance lifecycle
This commit is contained in:
parent
5eb47e8c09
commit
d544292a3c
|
|
@ -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)', () => {
|
describe('CLUB-2C.2: Membership Re-Entry Attack Matrix (Cases A - AF)', () => {
|
||||||
async function setupClub(db, clubId, admissionPolicy, archived = false) {
|
async function setupClub(db, clubId, admissionPolicy, archived = false) {
|
||||||
await setDoc(doc(db, `clubs/${clubId}`), {
|
await setDoc(doc(db, `clubs/${clubId}`), {
|
||||||
|
|
|
||||||
|
|
@ -777,15 +777,19 @@ service cloud.firestore {
|
||||||
'archivedAt', 'updatedAt'
|
'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))
|
&& (!('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)
|
isClubAdmin(request.auth.uid)
|
||||||
&& request.resource.data.diff(resource.data).affectedKeys().hasOnly([
|
&& request.resource.data.diff(resource.data).affectedKeys().hasOnly([
|
||||||
'name', 'description', 'logoUrl', 'bannerUrl',
|
'name', 'description', 'visibility', 'admissionPolicy',
|
||||||
'homeRegion', 'motorcycleFocus', 'updatedAt'
|
'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))
|
&& (!('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
|
// Case 4: Member count increment/decrement by membership transitions ONLY
|
||||||
(
|
(
|
||||||
|
|
|
||||||
|
|
@ -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>{
|
final updates = <String, dynamic>{
|
||||||
'updatedAt': FieldValue.serverTimestamp(),
|
'updatedAt': FieldValue.serverTimestamp(),
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -74,6 +74,7 @@ export function FounderSidebar({
|
||||||
function getRouteHref(id: string) {
|
function getRouteHref(id: string) {
|
||||||
if (id === 'overview') return `/clubs/${clubParam}/manage${queryParam}`;
|
if (id === 'overview') return `/clubs/${clubParam}/manage${queryParam}`;
|
||||||
if (id === 'profile') return `/clubs/${clubParam}/manage/profile${queryParam}`;
|
if (id === 'profile') return `/clubs/${clubParam}/manage/profile${queryParam}`;
|
||||||
|
if (id === 'settings') return `/clubs/${clubParam}/manage/settings${queryParam}`;
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -86,19 +87,21 @@ export function FounderSidebar({
|
||||||
const isActive = activeNav === item.id;
|
const isActive = activeNav === item.id;
|
||||||
const href = getRouteHref(item.id);
|
const href = getRouteHref(item.id);
|
||||||
const isProfile = item.id === 'profile';
|
const isProfile = item.id === 'profile';
|
||||||
|
const isSettings = item.id === 'settings';
|
||||||
|
const isSubRoute = isProfile || isSettings;
|
||||||
|
|
||||||
const content = (
|
const content = (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center gap-2.5">
|
<div className="flex items-center gap-2.5">
|
||||||
<span
|
<span
|
||||||
className={`material-symbols-outlined text-[18px] ${
|
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}
|
{item.icon}
|
||||||
</span>
|
</span>
|
||||||
<span className={isActive && isProfile ? 'text-secondary font-semibold' : ''}>
|
<span className={isActive && isSubRoute ? 'text-secondary font-semibold' : ''}>
|
||||||
{item.label}
|
{item.label}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -117,7 +120,7 @@ export function FounderSidebar({
|
||||||
{item.badge}
|
{item.badge}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{isActive && isProfile && !item.badge && (
|
{isActive && isSubRoute && !item.badge && (
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-secondary" />
|
<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 ${
|
const baseClasses = `w-full flex items-center justify-between px-3 py-2 rounded transition-colors text-xs ${
|
||||||
isActive
|
isActive
|
||||||
? isProfile
|
? isSubRoute
|
||||||
? 'bg-surface-container-high text-primary-container border-l-2 border-primary-container font-medium'
|
? 'bg-surface-container-high text-primary-container border-l-2 border-primary-container font-medium'
|
||||||
: 'bg-primary text-white font-medium'
|
: 'bg-primary text-white font-medium'
|
||||||
: 'text-on-surface-variant hover:bg-surface-container hover:text-on-surface font-normal'
|
: 'text-on-surface-variant hover:bg-surface-container hover:text-on-surface font-normal'
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,12 @@ export function FounderTopBar({
|
||||||
<span className="text-secondary font-semibold uppercase">CLUB PROFILE</span>
|
<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>
|
||||||
|
|
||||||
<div className="relative flex items-center shrink-0">
|
<div className="relative flex items-center shrink-0">
|
||||||
|
|
|
||||||
|
|
@ -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 { firestore } from './firebase-client';
|
||||||
import {
|
import {
|
||||||
Club,
|
Club,
|
||||||
|
|
@ -8,6 +8,8 @@ import {
|
||||||
UserPortalPermissions,
|
UserPortalPermissions,
|
||||||
ClubActivityItem,
|
ClubActivityItem,
|
||||||
ClubRole,
|
ClubRole,
|
||||||
|
ClubVisibility,
|
||||||
|
ClubAdmissionPolicy,
|
||||||
} from '../components/founder-portal/types';
|
} from '../components/founder-portal/types';
|
||||||
import {
|
import {
|
||||||
FIXTURE_FOUNDER_CLUB,
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ function loadTsModule(relativePath) {
|
||||||
collectionGroup: () => ({}),
|
collectionGroup: () => ({}),
|
||||||
updateDoc: async () => {},
|
updateDoc: async () => {},
|
||||||
serverTimestamp: () => new Date(),
|
serverTimestamp: () => new Date(),
|
||||||
|
runTransaction: async (_db, cb) => cb({ get: async () => ({ exists: () => false }), update: () => {} }),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (specifier.startsWith('.')) {
|
if (specifier.startsWith('.')) {
|
||||||
|
|
@ -61,6 +62,10 @@ const {
|
||||||
fetchManageableClubs,
|
fetchManageableClubs,
|
||||||
fetchFounderClubContext,
|
fetchFounderClubContext,
|
||||||
updateClubProfile,
|
updateClubProfile,
|
||||||
|
updateClubAccessSettings,
|
||||||
|
fetchEligibleTransferMembers,
|
||||||
|
transferClubOwnership,
|
||||||
|
archiveClub,
|
||||||
} = clubData;
|
} = clubData;
|
||||||
const { FIXTURE_FOUNDER_CLUB, FIXTURE_METRICS } = clubFixtures;
|
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(' 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) => {
|
runAllTests().catch((err) => {
|
||||||
|
|
@ -577,3 +725,4 @@ runAllTests().catch((err) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue