Skip to content
Open
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
65 changes: 65 additions & 0 deletions backend/src/routes/results.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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",
Expand Down Expand Up @@ -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<string, number> = {};
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,
Expand All @@ -65,6 +128,8 @@ router.get(
sorobanExplorerUrl: result.sorobanTxId
? explorerUrl(result.sorobanTxId)
: null,
metadata,
results: resultsByLabel,
},
});
} catch (err) {
Expand Down
163 changes: 163 additions & 0 deletions backend/src/tests/results.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading