Skip to content

Commit bc30b48

Browse files
authored
fix(react-components): gate CSP votes on real bundle membership (#243)
1 parent bc8e881 commit bc30b48

5 files changed

Lines changed: 228 additions & 65 deletions

File tree

packages/react-components/src/components/Election/VoteWeight.tsx

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { up } from 'up-fetch'
44
import { useComponents } from '~components/context/useComponents'
55
import { useReactComponentsLocalize } from '~i18n/localize'
66
import { useElection } from '~providers'
7+
import { fetchCheckMembership } from '~providers/csp'
78
import { results } from './Results'
89

910
const upfetch = up(fetch)
@@ -37,10 +38,21 @@ export const VoteWeight = () => {
3738
const decimals = (election.meta as any)?.token?.decimals || 0
3839
setWeight(results(Number(proof.weight), decimals))
3940
} else {
40-
const { weight } = await upfetch<{ weight: string }>(`${election.census.censusURI}/weight`, {
41-
method: 'POST',
42-
body: { authToken: token },
41+
// The bundle /check endpoint returns the voter weight alongside the
42+
// membership result, so prefer it and only fall back to the dedicated
43+
// /weight endpoint when it doesn't carry a weight.
44+
const check = await fetchCheckMembership({
45+
endpoint: election.census.censusURI,
46+
authToken: token as string,
47+
electionId: election.id,
4348
})
49+
let weight = check.belongs ? check.weight : undefined
50+
if (typeof weight === 'undefined') {
51+
;({ weight } = await upfetch<{ weight: string }>(`${election.census.censusURI}/weight`, {
52+
method: 'POST',
53+
body: { authToken: token },
54+
}))
55+
}
4456
setWeight(results(Number.parseInt(weight, 16)))
4557
}
4658
} catch (error) {

packages/react-components/src/providers/csp/index.test.ts

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { CensusType, CspProofType, PublishedElection, VocdoniSDKClient, Vote, WeightedCensus } from '@vocdoni/sdk'
2-
import { vote } from './index'
2+
import { fetchCheckMembership, vote } from './index'
33

44
const { upFetchMock } = vi.hoisted(() => ({
55
upFetchMock: vi.fn(),
@@ -47,3 +47,36 @@ describe('csp vote', () => {
4747
expect(client.submitVote).toHaveBeenCalledWith(expect.objectContaining({ weight: BigInt(10) }))
4848
})
4949
})
50+
51+
describe('fetchCheckMembership', () => {
52+
beforeEach(() => {
53+
upFetchMock.mockReset()
54+
})
55+
56+
it('posts to the bundle ${censusURI}/check endpoint with token and electionId', async () => {
57+
upFetchMock.mockResolvedValue({ belongs: true, weight: '0a', hasVoted: false })
58+
59+
const result = await fetchCheckMembership({
60+
endpoint: 'https://csp.example/process/bundle/abc',
61+
authToken: 'token',
62+
electionId: 'election-1',
63+
})
64+
65+
expect(upFetchMock).toHaveBeenCalledWith('https://csp.example/process/bundle/abc/check', {
66+
method: 'POST',
67+
body: { authToken: 'token', electionId: 'election-1' },
68+
})
69+
expect(result).toEqual({ belongs: true, weight: '0a', hasVoted: false })
70+
})
71+
72+
it('omits electionId from the body when not provided', async () => {
73+
upFetchMock.mockResolvedValue({ belongs: false, hasVoted: false })
74+
75+
await fetchCheckMembership({ endpoint: 'https://csp.example/process/bundle/abc', authToken: 'token' })
76+
77+
expect(upFetchMock).toHaveBeenCalledWith('https://csp.example/process/bundle/abc/check', {
78+
method: 'POST',
79+
body: { authToken: 'token' },
80+
})
81+
})
82+
})

packages/react-components/src/providers/csp/index.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,3 +51,28 @@ export const fetchSignInfo = ({
5151
method: 'POST',
5252
body: { authToken },
5353
})
54+
55+
type CheckMembershipResponse = {
56+
belongs: boolean
57+
weight?: string // hex
58+
hasVoted: boolean
59+
}
60+
61+
type CheckMembershipMutationVariables = {
62+
endpoint: string
63+
authToken: string
64+
electionId?: string
65+
}
66+
67+
// Posts to ${endpoint}/check, where endpoint is the bundle base
68+
// (the same base used by /sign and /weight, i.e. census.censusURI), which
69+
// resolves to POST /process/bundle/{bundleId}/check on the backend.
70+
export const fetchCheckMembership = ({
71+
endpoint,
72+
authToken,
73+
electionId,
74+
}: CheckMembershipMutationVariables): Promise<CheckMembershipResponse> =>
75+
f(`${endpoint}/check`, {
76+
method: 'POST',
77+
body: { authToken, ...(electionId ? { electionId } : {}) },
78+
})

packages/react-components/src/providers/election/ElectionProvider.test.tsx

Lines changed: 78 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { CensusType, EnvOptions, PublishedElection, VocdoniSDKClient, WeightedCe
44
import { act } from 'react'
55
import * as browserModule from '~providers/browser'
66
import { ClientContext, ClientProvider, useClient } from '~providers/client'
7-
import { fetchSignInfo } from '~providers/csp'
7+
import { fetchCheckMembership, fetchSignInfo } from '~providers/csp'
88
import { TestProvider, onlyProps, properProps } from '~providers/test-utils'
99
import * as webWorkerModule from '~providers/worker/webWorker'
1010
import { ElectionProvider, useElection } from './ElectionProvider'
@@ -17,13 +17,23 @@ vi.mock('~providers/csp', () => ({
1717
at: new Date().toISOString(),
1818
})
1919
),
20+
fetchCheckMembership: vi.fn(() => Promise.resolve({ belongs: true, hasVoted: false })),
2021
vote: vi.fn(),
2122
}))
2223

2324
describe('<ElectionProvider />', () => {
2425
beforeEach(() => {
2526
localStorage.removeItem('csp_token')
26-
vi.mocked(fetchSignInfo).mockClear()
27+
// mockReset (not mockClear) drains any *Once queued implementations so they
28+
// can't leak into later tests, then we re-establish the default behaviour.
29+
vi.mocked(fetchSignInfo).mockReset()
30+
vi.mocked(fetchSignInfo).mockResolvedValue({
31+
address: '0x0',
32+
nullifier: '0xdeadbeef',
33+
at: new Date().toISOString(),
34+
})
35+
vi.mocked(fetchCheckMembership).mockReset()
36+
vi.mocked(fetchCheckMembership).mockResolvedValue({ belongs: true, hasVoted: false })
2737
})
2838

2939
it('renders child elements', () => {
@@ -876,9 +886,65 @@ describe('<ElectionProvider />', () => {
876886
localStorage.removeItem('csp_token')
877887
})
878888

879-
it('enables voting when CSP token is set after initial render and sign-info returns 401', async () => {
880-
vi.mocked(fetchSignInfo).mockRejectedValueOnce({ status: 401 })
889+
it('gates CSP voters out of census when bundle check reports they do not belong', async () => {
890+
localStorage.setItem('csp_token', 'token')
891+
vi.mocked(fetchCheckMembership).mockResolvedValue({ belongs: false, hasVoted: false })
892+
893+
const signer = Wallet.createRandom()
894+
const client = new VocdoniSDKClient({
895+
env: EnvOptions.STG,
896+
wallet: signer,
897+
})
898+
client.voteService.info = vi.fn().mockResolvedValue({ voteID: null, overwriteCount: 0 })
899+
900+
const census = new WeightedCensus()
901+
census.type = CensusType.CSP
902+
census.censusURI = 'https://csp.example/api'
903+
904+
// @ts-ignore
905+
const election = PublishedElection.build({
906+
id: 'csp-election-not-belonging',
907+
title: 'test',
908+
description: 'test',
909+
endDate: new Date(),
910+
census,
911+
electionType: { anonymous: false },
912+
voteType: { maxVoteOverwrites: 0, maxCount: 1, maxValue: 1 },
913+
})
914+
915+
const wrapper = (props: any) => {
916+
return (
917+
<TestProvider>
918+
<ClientProvider {...onlyProps(props)}>
919+
<ElectionProvider {...properProps(props)} />
920+
</ClientProvider>
921+
</TestProvider>
922+
)
923+
}
924+
925+
const { result } = renderHook(() => useElection(), {
926+
wrapper,
927+
initialProps: { election, fetchCensus: true, client, signer },
928+
})
929+
930+
await waitFor(() => {
931+
expect(result.current.loaded.census).toBeTruthy()
932+
})
881933

934+
expect(fetchCheckMembership).toHaveBeenCalledWith(
935+
expect.objectContaining({
936+
endpoint: 'https://csp.example/api',
937+
authToken: 'token',
938+
electionId: 'csp-election-not-belonging',
939+
})
940+
)
941+
expect(result.current.isInCensus).toBeFalsy()
942+
expect(result.current.isAbleToVote).toBeFalsy()
943+
944+
localStorage.removeItem('csp_token')
945+
})
946+
947+
it('enables voting when the CSP token is set after the initial render', async () => {
882948
const client = new VocdoniSDKClient({
883949
env: EnvOptions.STG,
884950
})
@@ -925,9 +991,9 @@ describe('<ElectionProvider />', () => {
925991
expect(result.current.isAbleToVote).toBeTruthy()
926992
})
927993

928-
it('treats 401 on CSP sign info as no prior vote', async () => {
994+
it('grants full allowance to a CSP member who has not voted (check hasVoted=false)', async () => {
929995
localStorage.setItem('csp_token', 'token')
930-
vi.mocked(fetchSignInfo).mockRejectedValueOnce({ status: 401 })
996+
vi.mocked(fetchCheckMembership).mockResolvedValue({ belongs: true, hasVoted: false })
931997

932998
const signer = Wallet.createRandom()
933999
const client = new VocdoniSDKClient({
@@ -941,7 +1007,7 @@ describe('<ElectionProvider />', () => {
9411007

9421008
// @ts-ignore
9431009
const election = PublishedElection.build({
944-
id: 'csp-election-401',
1010+
id: 'csp-election-not-voted',
9451011
title: 'test',
9461012
description: 'test',
9471013
endDate: new Date(),
@@ -981,15 +1047,18 @@ describe('<ElectionProvider />', () => {
9811047
})
9821048

9831049
expect(result.current.election.voted).toBeNull()
984-
expect(result.current.election.loaded.voted).toBeFalsy()
9851050
expect(result.current.election.votesLeft).toBe(1)
9861051
expect(result.current.election.isAbleToVote).toBeTruthy()
1052+
// membership check resolved hasVoted directly, so no nullifier lookup is needed
1053+
expect(fetchSignInfo).not.toHaveBeenCalled()
9871054

9881055
localStorage.removeItem('csp_token')
9891056
})
9901057

9911058
it('sets CSP votesLeft to 0 when vote exists and overwrites are disabled', async () => {
9921059
localStorage.setItem('csp_token', 'token')
1060+
// hasVoted=true forces the exact-overwrite resolution via the nullifier
1061+
vi.mocked(fetchCheckMembership).mockResolvedValue({ belongs: true, hasVoted: true })
9931062

9941063
const signer = Wallet.createRandom()
9951064
const client = new VocdoniSDKClient({
@@ -1106,7 +1175,7 @@ describe('<ElectionProvider />', () => {
11061175
})
11071176

11081177
await waitFor(() => {
1109-
expect(result.current.election.loaded.voted).toBeTruthy()
1178+
expect(result.current.election.isAbleToVote).toBe(false)
11101179
})
11111180

11121181
expect(result.current.election.isInCensus).toBeFalsy()

0 commit comments

Comments
 (0)