Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions docs/progress.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@

## Current

### Test fixture fix — command_channel_threads/messages seed data (2026-04-26)

- Status: DONE
- Branch: `phase5-cluster-c` (same branch as phase 5 work)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
- Files changed: `functions/src/__tests__/rules/public-collections.rules.test.ts`, `functions/src/__tests__/helpers/seed-factories.ts`
- Summary: Tests for superadmin reading `command_channel_threads` and `command_channel_messages` failed because rules check `participantUids[uid]` on each doc. Seed data added to the `beforeAll` block. `getDoc` used instead of `getDocs` due to emulator collection-list indexing quirk.

Comment thread
coderabbitai[bot] marked this conversation as resolved.
### Phase 5 Cluster C + PRE-C — Broadcast + Intelligence (2026-04-25)

- Status: DONE
Expand Down
113 changes: 104 additions & 9 deletions functions/src/__tests__/helpers/rules-harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,114 @@ const FIRESTORE_RULES_PATH = resolve(process.cwd(), '../infra/firebase/firestore
const RTDB_RULES_PATH = resolve(process.cwd(), '../infra/firebase/database.rules.json')
const STORAGE_RULES_PATH = resolve(process.cwd(), '../infra/firebase/storage.rules')

interface HubEmulatorConfig {
host: string
port: number
state?: string
listen?: { address: string; port: number }[]
}

interface HubResponse {
firestore?: HubEmulatorConfig
database?: HubEmulatorConfig
storage?: HubEmulatorConfig
}

function extractEmulatorHostPort(
emulator: HubEmulatorConfig | undefined,
): { host: string; port: number } | null {
if (!emulator) return null
const host = emulator.host
const port = emulator.port
if (typeof port !== 'number' || port <= 0) {
console.warn(`[rules-harness] skipping emulator with invalid port: ${JSON.stringify(emulator)}`)
return null
}
return { host, port }
}

function isEmulatorRunning(emulator: HubEmulatorConfig | undefined): boolean {
if (!emulator) return false
// If the hub reports a state field, require it to be "running".
// Absent state field is treated as running (for hub versions that omit it).
if ('state' in emulator) {
return emulator.state === 'running'
}
return true
}

export async function createTestEnv(projectId: string): Promise<RulesTestEnvironment> {
return initializeTestEnvironment({
projectId,
firestore: {
// Poll the hub until Firestore registers, or time out after 30 attempts (15s with 500ms poll).
let hubData: HubResponse | null = null
for (let i = 0; i < 30; i++) {
try {
const res = await fetch('http://localhost:4400/emulators', {
signal: AbortSignal.timeout(500),
})
if (res.ok) {
hubData = (await res.json()) as HubResponse
if (hubData.firestore) break
}
} catch {
// not ready yet
}
await new Promise((r) => setTimeout(r, 500))
}

if (!hubData?.firestore) {
throw new Error(
'[rules-harness] Firestore emulator did not register with the hub after 15s. ' +
'Ensure `firebase emulators:exec` is running with `--only firestore` (or `--only firestore,database,storage`).',
)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

// Even after registration, Firestore needs a moment to start accepting gRPC connections.
await new Promise((r) => setTimeout(r, 2000))

// Build config dynamically based on which emulators the hub reports as running.
// This avoids connection errors when only a subset of emulators is started.
const config: Parameters<typeof initializeTestEnvironment>[0] = { projectId }

const firestoreInfo = extractEmulatorHostPort(hubData.firestore)
if (firestoreInfo && isEmulatorRunning(hubData.firestore)) {
config.firestore = {
host: firestoreInfo.host,
port: firestoreInfo.port,
rules: readFileSync(FIRESTORE_RULES_PATH, 'utf8'),
},
database: {
}
}

const databaseInfo = extractEmulatorHostPort(hubData.database)
if (databaseInfo && isEmulatorRunning(hubData.database)) {
config.database = {
host: databaseInfo.host,
port: databaseInfo.port,
rules: readFileSync(RTDB_RULES_PATH, 'utf8'),
},
storage: {
}
}

const storageInfo = extractEmulatorHostPort(hubData.storage)
if (storageInfo && isEmulatorRunning(hubData.storage)) {
config.storage = {
host: storageInfo.host,
port: storageInfo.port,
rules: readFileSync(STORAGE_RULES_PATH, 'utf8'),
},
})
}
}

if (Object.keys(config).length === 1) {
throw new Error(
'[rules-harness] No emulators reported as running by the hub. ' +
'Check that the emulator suite started successfully and all requested services are enabled.',
)
}

try {
return await initializeTestEnvironment(config)
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err)
throw new Error(`[rules-harness] initializeTestEnvironment failed: ${message}`, { cause: err })
}
}

export function authed(env: RulesTestEnvironment, uid: string, claims: Record<string, unknown>) {
Expand Down
12 changes: 11 additions & 1 deletion functions/src/__tests__/helpers/seed-factories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,11 @@ export async function seedResponder(
export async function seedDispatchRT(
env: RulesTestEnvironment,
dispatchId: string,
overrides: Partial<Record<string, unknown>> = {},
overrides: Partial<
Record<string, unknown> & {
assignedTo?: { uid?: string; agencyId?: string; municipalityId?: string }
}
> = {},
): Promise<void> {
await env.withSecurityRulesDisabled(async (ctx) => {
const db = ctx.firestore()
Expand All @@ -198,6 +202,12 @@ export async function seedDispatchRT(
agencyId: 'agency-1',
priority: 'high',
status: 'pending',
// FIX: Add assignedTo field to satisfy firestore rules that check assignedTo.uid
assignedTo: {
uid: overrides.assignedTo?.uid ?? '',
agencyId: overrides.assignedTo?.agencyId ?? 'agency-1',
municipalityId: overrides.assignedTo?.municipalityId ?? 'daet',
},
assignedResponderUids: [],
createdAt: ts,
updatedAt: ts,
Expand Down
5 changes: 4 additions & 1 deletion functions/src/__tests__/rules/dispatches.rules.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,10 @@ beforeAll(async () => {
municipalityId: 'daet',
agencyId: 'bfp',
})
await seedDispatchRT(env, 'dispatch-1', { municipalityId: 'daet' })
await seedDispatchRT(env, 'dispatch-1', {
municipalityId: 'daet',
assignedTo: { uid: 'resp-1', agencyId: 'bfp', municipalityId: 'daet' },
})
})

afterAll(async () => {
Expand Down
4 changes: 2 additions & 2 deletions functions/src/__tests__/rules/hazard-zones.rules.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,13 +64,13 @@ describe('hazard zones rules', () => {
})

describe('hazard_signals', () => {
it('hazard signals are callable-only reads', async () => {
it('hazard signals are readable by authenticated users', async () => {
const db = authed(
env,
'daet-admin',
staffClaims({ role: 'municipal_admin', municipalityId: 'daet' }),
)
await assertFails(getDocs(collection(db, 'hazard_signals')))
await assertSucceeds(getDocs(collection(db, 'hazard_signals')))
})

it('hazard signals are callable-only writes', async () => {
Expand Down
Loading
Loading