Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
112 changes: 112 additions & 0 deletions frontend/app/api/admin/audit-log/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { test } from "node:test"
import assert from "node:assert"

// ── Authorization logic (mirrors the server-side check in the route) ─────────

function isAuthorized(callerAddress: string, creatorAddress: string): boolean {
return callerAddress.toLowerCase() === creatorAddress.toLowerCase()
}

function getAuthErrorCode(
poolId: string | null,
callerAddress: string | null,
pool: { creator_address: string } | null,
): number | null {
if (!poolId) return 400
if (!callerAddress) return 400
if (!pool) return 404
if (!isAuthorized(callerAddress, pool.creator_address)) return 403
return null // authorized
}

// ── Consistency-check logic (mirrors the API route calculation) ──────────────

function computeConsistency(
activities: { activity_type: string; amount: number | null }[],
recorded: number,
): { inconsistent: boolean; activityNet: number } {
const activityNet = activities.reduce((sum, r) => {
const amt = r.amount ?? 0
const t = r.activity_type.toLowerCase()
if (t === "deposit") return sum + amt
if (t === "withdraw" || t === "payout") return sum - amt
return sum
}, 0)
return { inconsistent: Math.abs(activityNet - recorded) > 0.01, activityNet }
}

// ── Authorization tests ─────────────────────────────────────────────────────

test("authorization — returns 400 when poolId is missing", () => {
const code = getAuthErrorCode(null, "GABC", { creator_address: "GCREATOR" })
assert.strictEqual(code, 400)
})

test("authorization — returns 400 when callerAddress is missing", () => {
const code = getAuthErrorCode("p1", null, { creator_address: "GCREATOR" })
assert.strictEqual(code, 400)
})

test("authorization — returns 404 when pool is not found", () => {
const code = getAuthErrorCode("p1", "GABC", null)
assert.strictEqual(code, 404)
})

test("authorization — returns 403 when caller is not the pool creator", () => {
const code = getAuthErrorCode("p1", "GINTRUDER", { creator_address: "GCREATOR" })
assert.strictEqual(code, 403)
})

test("authorization — returns null (authorized) when caller matches creator", () => {
const code = getAuthErrorCode("p1", "GCREATOR", { creator_address: "GCREATOR" })
assert.strictEqual(code, null)
})

test("authorization — case-insensitive address comparison", () => {
assert.strictEqual(isAuthorized("gcreator", "GCREATOR"), true)
assert.strictEqual(isAuthorized("GCREATOR", "gcreator"), true)
assert.strictEqual(isAuthorized("gcreator123", "GCREATOR456"), false)
})

// ── Consistency-check tests ──────────────────────────────────────────────────

test("consistency check — consistent when net matches recorded", () => {
const acts = [
{ activity_type: "deposit", amount: 100 },
{ activity_type: "payout", amount: 40 },
]
const { inconsistent, activityNet } = computeConsistency(acts, 60)
assert.strictEqual(inconsistent, false)
assert.strictEqual(activityNet, 60)
})

test("consistency check — inconsistent when net diverges from recorded", () => {
const acts = [{ activity_type: "deposit", amount: 100 }]
const { inconsistent } = computeConsistency(acts, 90)
assert.strictEqual(inconsistent, true)
})

test("consistency check — tolerates floating-point differences <= 0.01", () => {
const acts = [{ activity_type: "deposit", amount: 100.001 }]
const { inconsistent } = computeConsistency(acts, 100)
assert.strictEqual(inconsistent, false)
})

test("consistency check — non-financial activity types are ignored", () => {
const acts = [
{ activity_type: "deposit", amount: 50 },
{ activity_type: "member_joined", amount: null },
]
const { inconsistent, activityNet } = computeConsistency(acts, 50)
assert.strictEqual(inconsistent, false)
assert.strictEqual(activityNet, 50)
})

test("consistency check — withdraw reduces the net", () => {
const acts = [
{ activity_type: "deposit", amount: 200 },
{ activity_type: "withdraw", amount: 80 },
]
const { activityNet } = computeConsistency(acts, 120)
assert.strictEqual(activityNet, 120)
})
18 changes: 14 additions & 4 deletions frontend/app/api/admin/audit-log/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,15 @@ export interface AuditRow {
}

/**
* GET /api/admin/audit-log?poolId=<id>
* GET /api/admin/audit-log?poolId=<id>&callerAddress=<address>
*
* Returns all pool_activity rows for the given pool together with a
* per-pool consistency flag: `inconsistent` is true when the pool's
* recorded `total_saved` diverges from the sum of deposit/withdrawal
* activity rows by more than 0.01 (floating-point tolerance).
*
* Only the pool creator should call this route; the component enforces
* that guard on the client side. A missing poolId returns 400.
* The caller's wallet address is verified server-side against the
* pool's `creator_address` before any data is returned.
*/
export async function GET(req: NextRequest) {
const limited = readLimiter(req)
Expand All @@ -36,16 +36,26 @@ export async function GET(req: NextRequest) {
return NextResponse.json({ error: "poolId is required" }, { status: 400 })
}

const callerAddress = req.nextUrl.searchParams.get("callerAddress")
if (!callerAddress) {
return NextResponse.json({ error: "callerAddress is required" }, { status: 400 })
}

const { data: pool, error: poolErr } = await supabase
.from("pools")
.select("id, name, total_saved")
.select("id, name, total_saved, creator_address")
.eq("id", poolId)
.single()

if (poolErr || !pool) {
return NextResponse.json({ error: "Pool not found" }, { status: 404 })
}

// Server-side authorization: reject if caller is not the pool creator
if (callerAddress.toLowerCase() !== pool.creator_address.toLowerCase()) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
}

const { data: activity, error: actErr } = await supabase
.from("pool_activity")
.select("id, pool_id, activity_type, user_address, amount, tx_hash, description, created_at")
Expand Down
9 changes: 6 additions & 3 deletions frontend/components/group/admin-audit-log.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,11 @@ export function AdminAuditLog({ groupId, creatorAddress }: AdminAuditLogProps) {
useEffect(() => {
if (!isCreator) return
setLoading(true)
fetch(`/api/admin/audit-log?poolId=${groupId}`)
.then((r) => r.json())
fetch(`/api/admin/audit-log?poolId=${groupId}&callerAddress=${address}`)
.then((r) => {
if (r.status === 403) throw new Error("You are not authorized to view this audit log")
return r.json()
})
.then((data) => {
if (data.error) throw new Error(data.error)
setRows(data.rows)
Expand All @@ -50,7 +53,7 @@ export function AdminAuditLog({ groupId, creatorAddress }: AdminAuditLogProps) {
})
.catch((e) => setError(e.message))
.finally(() => setLoading(false))
}, [isCreator, groupId])
}, [isCreator, groupId, address])

if (!isCreator) return null

Expand Down
2 changes: 1 addition & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
"dev": "next dev --webpack",
"lint": "next lint",
"start": "next start",
"test:unit": "tsx --test lib/csv-export.test.ts lib/analytics.test.ts lib/pool-health.test.ts lib/form-validation.test.ts hooks/use-keyboard-shortcuts.test.ts",
"test:unit": "tsx --test lib/csv-export.test.ts lib/analytics.test.ts lib/pool-health.test.ts lib/form-validation.test.ts hooks/use-keyboard-shortcuts.test.ts app/api/admin/audit-log/route.test.ts",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"test:e2e:report": "playwright show-report"
Expand Down
Loading