From 627613f407762181dda1bf0cc8277b15eec309f2 Mon Sep 17 00:00:00 2001 From: kenny Date: Tue, 28 Jul 2026 19:58:23 +0100 Subject: [PATCH] feat(results): add audit metadata to results endpoint --- backend/src/routes/results.ts | 65 ++++++++++++ backend/src/tests/results.test.ts | 163 +++++++++++++++++++++++++++++ frontend/src/pages/ResultsPage.tsx | 125 ++++++++++++++++++++-- frontend/src/types/index.ts | 13 +++ 4 files changed, 360 insertions(+), 6 deletions(-) create mode 100644 backend/src/tests/results.test.ts diff --git a/backend/src/routes/results.ts b/backend/src/routes/results.ts index 35c3b926..0205e50d 100644 --- a/backend/src/routes/results.ts +++ b/backend/src/routes/results.ts @@ -1,5 +1,7 @@ +import crypto from "crypto"; import { Router, Request, Response, NextFunction } from "express"; import { getResult, tallyBallot } from "../services/resultEngine"; +import { sorobanGetAuditCounts } from "../services/sorobanService"; import { requireAuth } from "../middleware/auth"; import { prisma } from "../prisma/client"; import { notFound, badRequest } from "../utils/errors"; @@ -14,6 +16,45 @@ function explorerUrl(txHash: string): string { return `https://stellar.expert/explorer/${network}/tx/${txHash}`; } +/** + * Plain-language explanation of the privacy/verification model, surfaced to + * observers alongside the results so they can understand what "verified" + * means for this tally without reading the source code. + */ +const ENCRYPTION_NOTE = + "Each vote is encrypted at submission time with AES-256-GCM using the " + + "ballot's encryption key. Votes are never stored or transmitted in " + + "plaintext — they are decrypted only in aggregate, during tallying, to " + + "compute the counts below. Individual vote payloads are not exposed by " + + "this API."; + +/** + * Determine the on-chain consistency flag for a ballot's result. + * + * Falls back to the DB-level consistency check (weighted votes vs. used + * tokens, computed in resultEngine) whenever the Soroban contract isn't + * deployed/configured (SOROBAN_CONTRACT_ID unset — see issue #12), so the + * flag always reflects the best verification available rather than + * silently reporting false. + */ +async function resolveIsConsistent( + ballotId: string, + dbIsConsistent: boolean, +): Promise<{ isConsistent: boolean; source: "contract" | "database" }> { + if (!config.sorobanContractId) { + return { isConsistent: dbIsConsistent, source: "database" }; + } + + const ballotIdHash = crypto.createHash("sha256").update(ballotId).digest("hex"); + const onChain = await sorobanGetAuditCounts(ballotIdHash).catch(() => null); + + if (!onChain) { + return { isConsistent: dbIsConsistent, source: "database" }; + } + + return { isConsistent: onChain.isConsistent, source: "contract" }; +} + // GET /api/results/:ballotId — Public: enriched result with option breakdown router.get( "/:ballotId", @@ -53,9 +94,31 @@ router.get( ? Math.round((result.totalVotes / tokensIssued) * 10000) / 100 : 0; + const { isConsistent, source: consistencySource } = + await resolveIsConsistent(ballotId, result.isConsistent); + + // Simple label -> count map for observers/auditors (e.g. { "Option A": 45 }) + const resultsByLabel: Record = {}; + options.forEach((opt) => { + resultsByLabel[opt.optionText] = opt.count; + }); + + const metadata = { + ballot_id: ballotId, + ballot_title: ballot.topic, + total_votes: result.totalVotes, + tally_timestamp: result.publishedAt, + stellar_transaction_id: result.stellarTxId ?? null, + soroban_transaction_id: result.sorobanTxId ?? null, + is_consistent: isConsistent, + consistency_source: consistencySource, + encryption_note: ENCRYPTION_NOTE, + }; + res.status(200).json({ data: { ...result, + isConsistent, options, participationRate, tokensIssued, @@ -65,6 +128,8 @@ router.get( sorobanExplorerUrl: result.sorobanTxId ? explorerUrl(result.sorobanTxId) : null, + metadata, + results: resultsByLabel, }, }); } catch (err) { diff --git a/backend/src/tests/results.test.ts b/backend/src/tests/results.test.ts new file mode 100644 index 00000000..a774a635 --- /dev/null +++ b/backend/src/tests/results.test.ts @@ -0,0 +1,163 @@ +/** + * Unit tests for GET /api/results/:ballotId + * + * Covers issue #38 — results response must include a `metadata` object + * with total_votes, ballot_id, ballot_title, tally_timestamp, the Stellar + * transaction id, an is_consistent flag, and an encryption_note. + */ +import request from "supertest"; +import app from "../app"; +import { prisma } from "../prisma/client"; +import { generateToken, hashToken } from "../utils/crypto"; +import { tallyBallot } from "../services/resultEngine"; + +let ballotId: string; +let optionAId: string; +let optionBId: string; +let eligibilityListId: string; + +beforeAll(async () => { + await prisma.auditEvent.deleteMany(); + await prisma.voterToken.deleteMany(); + await prisma.vote.deleteMany(); + await prisma.result.deleteMany(); + await prisma.ballot.deleteMany(); + await prisma.eligibilityEntry.deleteMany(); + await prisma.eligibilityList.deleteMany(); + await prisma.session.deleteMany(); + await prisma.organization.deleteMany(); + + const org = await prisma.organization.create({ + data: { + name: "Results Metadata Test Org", + email: "results-metadata@test.com", + passwordHash: "irrelevant", + }, + }); + + const list = await prisma.eligibilityList.create({ data: {} }); + eligibilityListId = list.id; + + const ballot = await prisma.ballot.create({ + data: { + organizationId: org.id, + topic: "Results Metadata Test Ballot", + deadline: new Date(Date.now() + 3_600_000), + eligibilityListId, + status: "CLOSED", + options: { create: [{ text: "Option A" }, { text: "Option B" }] }, + }, + include: { options: true }, + }); + ballotId = ballot.id; + optionAId = ballot.options.find((o) => o.text === "Option A")!.id; + optionBId = ballot.options.find((o) => o.text === "Option B")!.id; + + const { encryptVote } = await import("../utils/crypto"); + const key = process.env.BALLOT_ENCRYPTION_KEY ?? "test-key-32bytes!padding123456"; + + await prisma.vote.create({ + data: { + ballotId, + optionId: optionAId, + encryptedPayload: encryptVote(optionAId, key), + weight: 1, + }, + }); + await prisma.vote.create({ + data: { + ballotId, + optionId: optionAId, + encryptedPayload: encryptVote(optionAId, key), + weight: 1, + }, + }); + await prisma.vote.create({ + data: { + ballotId, + optionId: optionBId, + encryptedPayload: encryptVote(optionBId, key), + weight: 1, + }, + }); + + for (let i = 0; i < 3; i++) { + await prisma.voterToken.create({ + data: { tokenHash: hashToken(generateToken()), ballotId, used: true }, + }); + } + + await tallyBallot(ballotId, { skipSoroban: true }); +}); + +afterAll(() => prisma.$disconnect()); + +describe("GET /api/results/:ballotId — metadata", () => { + it("returns a metadata object alongside the results", async () => { + const res = await request(app).get(`/api/results/${ballotId}`); + + expect(res.status).toBe(200); + expect(res.body.data.metadata).toBeDefined(); + }); + + it("includes total_votes matching the sum of option counts", async () => { + const res = await request(app).get(`/api/results/${ballotId}`); + const { metadata, options } = res.body.data; + + const sum = options.reduce( + (acc: number, o: { count: number }) => acc + o.count, + 0, + ); + expect(metadata.total_votes).toBe(sum); + expect(metadata.total_votes).toBe(3); + }); + + it("includes ballot_id and ballot_title", async () => { + const res = await request(app).get(`/api/results/${ballotId}`); + const { metadata } = res.body.data; + + expect(metadata.ballot_id).toBe(ballotId); + expect(metadata.ballot_title).toBe("Results Metadata Test Ballot"); + }); + + it("includes a tally_timestamp", async () => { + const res = await request(app).get(`/api/results/${ballotId}`); + const { metadata } = res.body.data; + + expect(metadata.tally_timestamp).toBeTruthy(); + expect(new Date(metadata.tally_timestamp).toString()).not.toBe("Invalid Date"); + }); + + it("includes the Stellar transaction id field (nullable if unset)", async () => { + const res = await request(app).get(`/api/results/${ballotId}`); + const { metadata } = res.body.data; + + expect(metadata).toHaveProperty("stellar_transaction_id"); + }); + + it("is_consistent flag matches the underlying consistency check", async () => { + const res = await request(app).get(`/api/results/${ballotId}`); + const { metadata, isConsistent } = res.body.data; + + expect(metadata.is_consistent).toBe(isConsistent); + expect(metadata.is_consistent).toBe(true); + // No SOROBAN_CONTRACT_ID configured in test env, so this should + // fall back to the database-level consistency check. + expect(metadata.consistency_source).toBe("database"); + }); + + it("includes a non-empty encryption_note explaining the privacy model", async () => { + const res = await request(app).get(`/api/results/${ballotId}`); + const { metadata } = res.body.data; + + expect(typeof metadata.encryption_note).toBe("string"); + expect(metadata.encryption_note.length).toBeGreaterThan(0); + }); + + it("returns 404 for a ballot with no published result", async () => { + const res = await request(app).get( + "/api/results/00000000-0000-0000-0000-000000000000", + ); + expect(res.status).toBe(404); + }); +}); diff --git a/frontend/src/pages/ResultsPage.tsx b/frontend/src/pages/ResultsPage.tsx index 77467831..13c7f293 100644 --- a/frontend/src/pages/ResultsPage.tsx +++ b/frontend/src/pages/ResultsPage.tsx @@ -166,6 +166,94 @@ function CopyLinkButton() { ); } +// ── Consistency Badge ───────────────────────────────────────────────────────── +function ConsistencyBadge({ + isConsistent, + source, +}: { + isConsistent: boolean; + source: "contract" | "database"; +}) { + const label = isConsistent ? "Verified consistent" : "Inconsistency detected"; + const sourceLabel = + source === "contract" ? "on-chain contract check" : "database check"; + + return ( + + + {isConsistent ? ( + + ) : ( + + )} + + {label} + + ); +} + +// ── Encryption Note (collapsible) ───────────────────────────────────────────── +function EncryptionNote({ note }: { note: string }) { + const [open, setOpen] = useState(false); + + return ( +
+ + {open && ( +

+ {note} +

+ )} +
+ ); +} + // ── Blockchain Anchor Card ──────────────────────────────────────────────────── function AnchorCard({ label, txId, explorerUrl }: { label: string; txId: string; explorerUrl: string }) { return ( @@ -301,7 +389,7 @@ export default function ResultsPage() {
{/* Inconsistency Warning */} - {!result.isConsistent && ( + {!(result.metadata?.is_consistent ?? result.isConsistent) && (
@@ -316,12 +404,29 @@ export default function ResultsPage() { {/* Vote Breakdown */}
-

- Vote Breakdown -

+

+ Vote Breakdown +

+ {result.metadata && ( + + )} +
+ {(result.metadata?.tally_timestamp ?? result.publishedAt) && ( +

+ Tallied {new Date(result.metadata?.tally_timestamp ?? result.publishedAt).toLocaleString()} +

+ )} + {result.metadata?.encryption_note && ( + + )}
{/* Participation */} diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index d1c9e4eb..0498dbc1 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -70,6 +70,18 @@ export interface Vote { submittedAt: string; } +export interface ResultMetadata { + ballot_id: string; + ballot_title: string; + total_votes: number; + tally_timestamp: string; + stellar_transaction_id: string | null; + soroban_transaction_id: string | null; + is_consistent: boolean; + consistency_source: "contract" | "database"; + encryption_note: string; +} + export interface Result { id: string; ballotId: string; @@ -88,6 +100,7 @@ export interface Result { tokensIssued?: number; explorerUrl?: string; sorobanExplorerUrl?: string; + metadata?: ResultMetadata; } export interface AuditEvent {