• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

vocdoni / saas-backend / 29728831431

20 Jul 2026 08:43AM UTC coverage: 62.429% (+0.3%) from 62.167%
29728831431

Pull #582

github

lucasmenendez
fix(jobs): keep member-import error PII off the public job endpoint

The public GET /jobs/{jobId} builds its response via JobResponseFromDB, which
surfaces job.Errors. For member-import jobs those strings embed raw member data
— plaintext email (db/org_members.go), plaintext phone, and the birthdate
(internal.ParseBirthDate) — so anyone holding a leaked jobId could read member
PII with no auth (an authorization downgrade introduced when the endpoint moved
to the unified job shape).

Clear the errors array for import job types on the public single-job endpoint;
the Manager/Admin-gated GET /jobs list keeps full detail (admins uploaded the
data and need the failing value to fix their import). tx-job errors are chain
failures with no PII and are unaffected. Adds a test asserting the public
endpoint omits the errors while the auth list retains them.
Pull Request #582: chore(api): /processes migration cleanup — deprecations, RESTful shapes, jobs/apikeys consolidation, param standardization

344 of 550 new or added lines in 19 files covered. (62.55%)

3 existing lines in 2 files now uncovered.

11796 of 18895 relevant lines covered (62.43%)

45.45 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

52.72
/api/processes_admin.go
1
package api
2

3
import (
4
        "encoding/json"
5
        stderrors "errors"
6
        "net/http"
7

8
        "github.com/vocdoni/saas-backend/account"
9
        "github.com/vocdoni/saas-backend/api/apicommon"
10
        "github.com/vocdoni/saas-backend/db"
11
        "github.com/vocdoni/saas-backend/errors"
12
        "go.vocdoni.io/dvote/log"
13
)
14

15
// deleteVotingProcessHandler godoc
16
//
17
//        @Summary                Delete a voting process draft
18
//        @Description        Delete an unpublished voting process draft together with its inline census. A
19
//        @Description        published process has on-chain elections and cannot be deleted. Requires
20
//        @Description        Manager/Admin role of the organization that owns the process.
21
//        @Tags                        processes
22
//        @Produce                json
23
//        @Security                BearerAuth
24
//        @Param                        processId        path                string                        true        "Process ID"
25
//        @Success                200                        {string}        string                        "OK"
26
//        @Failure                401                        {object}        errors.Error        "Unauthorized"
27
//        @Failure                404                        {object}        errors.Error        "Process not found"
28
//        @Failure                409                        {object}        errors.Error        "Process already published"
29
//        @Failure                500                        {object}        errors.Error        "Internal server error"
30
//        @Router                        /processes/{processId} [delete]
31
func (a *API) deleteVotingProcessHandler(w http.ResponseWriter, r *http.Request) {
3✔
32
        oid, ok := a.votingProcessID(w, r)
3✔
33
        if !ok {
3✔
NEW
34
                return
×
NEW
35
        }
×
36
        // loads the process + questions and gates on Manager/Admin of the owning org.
37
        vp, _, ok := a.authorizeStatusChange(w, r, oid)
3✔
38
        if !ok {
4✔
39
                return
1✔
40
        }
1✔
41
        // only a draft can be deleted; a published process lives on-chain and is immutable.
42
        if vp.Published {
3✔
43
                errors.ErrDuplicateConflict.Withf("process already published and not in draft mode").Write(w)
1✔
44
                return
1✔
45
        }
1✔
46
        if err := a.db.DeleteVotingProcess(oid); err != nil {
1✔
NEW
47
                errors.ErrGenericInternalServerError.WithErr(err).Write(w)
×
NEW
48
                return
×
NEW
49
        }
×
50
        // best-effort: drop the draft's inline census so it is not orphaned.
51
        if !vp.CensusID.IsZero() {
2✔
52
                _ = a.db.DelCensus(vp.CensusID.Hex())
1✔
53
        }
1✔
54
        apicommon.HTTPWriteOK(w)
1✔
55
}
56

57
// votingProcessParticipantsHandler godoc
58
//
59
//        @Summary                List voted participants of a voting process
60
//        @Description        Manager/Admin lookup of organization members by a single field (email, phone,
61
//        @Description        memberNumber, nationalId), intersected with the process census, reporting each
62
//        @Description        matched member's per-question voted status. For `phone` pass the plaintext number;
63
//        @Description        it is hashed server-side. Requires Manager/Admin of the owning organization.
64
//        @Tags                        processes
65
//        @Produce                json
66
//        @Security                BearerAuth
67
//        @Param                        processId        path                string        true        "Process ID"
68
//        @Param                        field                query                string        true        "Lookup field: email, phone, memberNumber or nationalId"
69
//        @Param                        value                query                string        true        "Value to match for the given field"
70
//        @Success                200                        {object}        apicommon.ProcessParticipantsResponse
71
//        @Failure                400                        {object}        errors.Error        "Invalid input data"
72
//        @Failure                401                        {object}        errors.Error        "Unauthorized"
73
//        @Failure                404                        {object}        errors.Error        "Process not found"
74
//        @Failure                500                        {object}        errors.Error        "Internal server error"
75
//        @Router                        /processes/{processId}/participants [get]
76
func (a *API) votingProcessParticipantsHandler(w http.ResponseWriter, r *http.Request) {
1✔
77
        oid, ok := a.votingProcessID(w, r)
1✔
78
        if !ok {
1✔
NEW
79
                return
×
NEW
80
        }
×
81
        vp, questions, ok := a.authorizeStatusChange(w, r, oid)
1✔
82
        if !ok {
1✔
NEW
83
                return
×
NEW
84
        }
×
85
        field := db.OrgMemberLookupField(r.URL.Query().Get("field"))
1✔
86
        if !field.IsValid() {
1✔
NEW
87
                errors.ErrMalformedBody.Withf("invalid field: must be one of email, phone, memberNumber, nationalId").Write(w)
×
NEW
88
                return
×
NEW
89
        }
×
90
        value := r.URL.Query().Get("value")
1✔
91
        if value == "" {
1✔
NEW
92
                errors.ErrMalformedBody.Withf("missing value").Write(w)
×
NEW
93
                return
×
NEW
94
        }
×
95
        // phone is stored hashed, so hash the plaintext before looking up.
96
        var lookupValue any = value
1✔
97
        if field == db.OrgMemberLookupFieldPhone {
1✔
NEW
98
                org, err := a.db.Organization(vp.OrgAddress)
×
NEW
99
                if err != nil {
×
NEW
100
                        errors.ErrGenericInternalServerError.WithErr(err).Write(w)
×
NEW
101
                        return
×
NEW
102
                }
×
NEW
103
                hashed, err := db.NewHashedPhone(value, org)
×
NEW
104
                if err != nil || hashed.IsEmpty() {
×
NEW
105
                        errors.ErrMalformedBody.Withf("invalid phone").Write(w)
×
NEW
106
                        return
×
NEW
107
                }
×
NEW
108
                lookupValue = hashed
×
109
        }
110

111
        resp := apicommon.ProcessParticipantsResponse{Participants: []apicommon.ProcessParticipantEntry{}}
1✔
112
        members, err := a.db.OrgMembersByField(vp.OrgAddress, field, lookupValue)
1✔
113
        if err != nil {
1✔
NEW
114
                errors.ErrGenericInternalServerError.WithErr(err).Write(w)
×
NEW
115
                return
×
NEW
116
        }
×
117
        if len(members) == 0 {
1✔
NEW
118
                apicommon.HTTPWriteJSON(w, resp)
×
NEW
119
                return
×
NEW
120
        }
×
121
        memberIDs := make([]string, 0, len(members))
1✔
122
        membersByID := make(map[string]*db.OrgMember, len(members))
1✔
123
        for _, m := range members {
2✔
124
                id := m.ID.Hex()
1✔
125
                memberIDs = append(memberIDs, id)
1✔
126
                membersByID[id] = m
1✔
127
        }
1✔
128
        participants, err := a.db.CensusParticipantsByMemberIDs(vp.CensusID.Hex(), memberIDs)
1✔
129
        if err != nil {
1✔
NEW
130
                errors.ErrGenericInternalServerError.WithErr(err).Write(w)
×
NEW
131
                return
×
NEW
132
        }
×
133
        if len(participants) == 0 {
1✔
NEW
134
                apicommon.HTTPWriteJSON(w, resp)
×
NEW
135
                return
×
NEW
136
        }
×
137
        participantIDs := make([]string, 0, len(participants))
1✔
138
        for _, p := range participants {
2✔
139
                participantIDs = append(participantIDs, p.ParticipantID)
1✔
140
        }
1✔
141
        // per-question voted status: each question is its own on-chain election, keyed by upstreamId.
142
        votedByQuestion := make(map[string]map[string]bool, len(questions))
1✔
143
        for i := range questions {
3✔
144
                q := &questions[i]
2✔
145
                if len(q.UpstreamID) == 0 {
2✔
NEW
146
                        continue // question not yet on chain
×
147
                }
148
                voted, err := a.db.MembersWithUsedCSPProcess(q.UpstreamID, participantIDs)
2✔
149
                if err != nil {
2✔
NEW
150
                        errors.ErrGenericInternalServerError.WithErr(err).Write(w)
×
NEW
151
                        return
×
NEW
152
                }
×
153
                votedByQuestion[q.ID.Hex()] = voted
2✔
154
        }
155
        for _, p := range participants {
2✔
156
                m, exists := membersByID[p.ParticipantID]
1✔
157
                if !exists {
1✔
NEW
158
                        continue
×
159
                }
160
                entry := apicommon.ProcessParticipantEntry{
1✔
161
                        MemberID:     m.ID.Hex(),
1✔
162
                        Name:         m.Name,
1✔
163
                        Surname:      m.Surname,
1✔
164
                        Email:        m.Email,
1✔
165
                        MemberNumber: m.MemberNumber,
1✔
166
                }
1✔
167
                for i := range questions {
3✔
168
                        q := &questions[i]
2✔
169
                        if len(q.UpstreamID) == 0 {
2✔
NEW
170
                                continue
×
171
                        }
172
                        entry.Questions = append(entry.Questions, apicommon.ProcessParticipantQuestionVote{
2✔
173
                                QuestionID: q.ID.Hex(),
2✔
174
                                UpstreamID: q.UpstreamID,
2✔
175
                                HasVoted:   votedByQuestion[q.ID.Hex()][m.ID.Hex()],
2✔
176
                        })
2✔
177
                }
178
                resp.Participants = append(resp.Participants, entry)
1✔
179
        }
180
        apicommon.HTTPWriteJSON(w, resp)
1✔
181
}
182

183
// updateVotingProcessCensusHandler godoc
184
//
185
//        @Summary                Add members to a published process's census
186
//        @Description        Add existing organization members to the census of an already-published voting process
187
//        @Description        (same behaviour as POST /census/{id}, resolving the census from the process) and raise
188
//        @Description        each affected on-chain election's maxCensusSize so the new members can vote. Members are
189
//        @Description        added synchronously; the maxCensusSize update runs as an async job (poll GET /jobs/{jobId}).
190
//        @Description        Questions with an eligibility subset keep their fixed size and are unaffected. Requires
191
//        @Description        Manager/Admin role and is subject to the plan's census quota.
192
//        @Tags                        processes
193
//        @Accept                        json
194
//        @Produce                json
195
//        @Security                BearerAuth
196
//        @Param                        processId        path                string                                                                        true        "Process ID"
197
//        @Param                        request                body                apicommon.AddCensusParticipantsRequest        true        "Member IDs to add"
198
//        @Success                200                        {object}        apicommon.UpdateProcessCensusResponse        "Members added; no on-chain resize needed"
199
//        @Success                202                        {object}        apicommon.UpdateProcessCensusResponse        "Members added; maxCensusSize update enqueued"
200
//        @Failure                400                        {object}        errors.Error                                                        "Invalid input data"
201
//        @Failure                401                        {object}        errors.Error                                                        "Unauthorized"
202
//        @Failure                404                        {object}        errors.Error                                                        "Process not found"
203
//        @Failure                409                        {object}        errors.Error                                                        "Process is not published"
204
//        @Failure                500                        {object}        errors.Error                                                        "Internal server error"
205
//        @Router                        /processes/{processId}/census [put]
206
func (a *API) updateVotingProcessCensusHandler(w http.ResponseWriter, r *http.Request) {
1✔
207
        oid, ok := a.votingProcessID(w, r)
1✔
208
        if !ok {
1✔
NEW
209
                return
×
NEW
210
        }
×
211
        // loads the process + questions and gates on Manager/Admin of the owning org.
212
        vp, questions, ok := a.authorizeStatusChange(w, r, oid)
1✔
213
        if !ok {
1✔
NEW
214
                return
×
NEW
215
        }
×
216
        // only a published process can have its on-chain census extended; drafts use PUT /processes.
217
        if !vp.Published {
1✔
NEW
218
                errors.ErrDuplicateConflict.Withf("process is not published; edit the draft via PUT /processes/{processId}").Write(w)
×
NEW
219
                return
×
NEW
220
        }
×
221
        census, err := a.db.Census(vp.CensusID.Hex())
1✔
222
        if err != nil {
1✔
NEW
223
                errors.ErrGenericInternalServerError.WithErr(err).Write(w)
×
NEW
224
                return
×
NEW
225
        }
×
226

227
        var req apicommon.AddCensusParticipantsRequest
1✔
228
        if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
1✔
NEW
229
                errors.ErrMalformedBody.Withf("couldn't decode participant IDs").Write(w)
×
NEW
230
                return
×
NEW
231
        }
×
232
        if len(req.MemberIDs) == 0 {
1✔
NEW
233
                apicommon.HTTPWriteJSON(w, &apicommon.UpdateProcessCensusResponse{Added: 0})
×
NEW
234
                return
×
NEW
235
        }
×
236

237
        if err := a.subscriptions.OrgCanAddCensusParticipants(census.OrgAddress, census.ID.Hex(), len(req.MemberIDs)); err != nil {
1✔
NEW
238
                writeSubscriptionError(w, err)
×
NEW
239
                return
×
NEW
240
        }
×
241

242
        added, memberErrs, err := a.db.AddCensusParticipantsByMemberIDs(census.ID.Hex(), req.MemberIDs)
1✔
243
        switch {
1✔
244
        case err == nil:
1✔
NEW
245
        case stderrors.Is(err, db.ErrInvalidData), stderrors.Is(err, db.ErrUpdateWouldCreateDuplicates):
×
NEW
246
                errors.ErrInvalidData.WithErr(err).Write(w)
×
NEW
247
                return
×
NEW
248
        case stderrors.Is(err, db.ErrNotFound):
×
NEW
249
                errors.ErrCensusNotFound.Write(w)
×
NEW
250
                return
×
NEW
251
        default:
×
NEW
252
                errors.ErrGenericInternalServerError.WithErr(err).Write(w)
×
NEW
253
                return
×
254
        }
255

256
        // recount and persist the census size so the on-chain maxCensusSize we set below is correct.
257
        size, err := a.db.CountCensusParticipants(census.ID.Hex())
1✔
258
        if err != nil {
1✔
NEW
259
                errors.ErrGenericInternalServerError.WithErr(err).Write(w)
×
NEW
260
                return
×
NEW
261
        }
×
262
        census.Size = size
1✔
263
        if _, err := a.db.SetCensus(census); err != nil {
1✔
NEW
264
                errors.ErrGenericInternalServerError.WithErr(err).Write(w)
×
NEW
265
                return
×
NEW
266
        }
×
267

268
        jobID, ok := a.enqueueCensusSizeUpdate(w, vp, census, questions)
1✔
269
        if !ok {
1✔
NEW
270
                return
×
NEW
271
        }
×
272
        resp := &apicommon.UpdateProcessCensusResponse{JobID: jobID, Added: uint32(added), Errors: memberErrs}
1✔
273
        if jobID == "" {
1✔
NEW
274
                // no whole-census election to resize (subset-only questions): nothing async is pending, so 200.
×
NEW
275
                apicommon.HTTPWriteJSON(w, resp)
×
NEW
276
                return
×
NEW
277
        }
×
278
        apicommon.HTTPWriteJSONStatus(w, http.StatusAccepted, resp)
1✔
279
}
280

281
// enqueueCensusSizeUpdate submits a SET_PROCESS_CENSUS tx per published whole-census question to raise
282
// its on-chain maxCensusSize to the census's new size. Questions with an eligibility subset keep their
283
// fixed size and are skipped. Returns the job id (empty when nothing on-chain needs updating) and false
284
// on failure (after writing the error response).
285
func (a *API) enqueueCensusSizeUpdate(
286
        w http.ResponseWriter, vp *db.VotingProcess, census *db.Census, questions []db.VotingProcessQuestion,
287
) (string, bool) {
1✔
288
        published := make([]db.VotingProcessQuestion, 0, len(questions))
1✔
289
        for i := range questions {
3✔
290
                if len(questions[i].UpstreamID) > 0 && len(questions[i].EligibleMemberIDs) == 0 {
3✔
291
                        published = append(published, questions[i])
1✔
292
                }
1✔
293
        }
294
        if len(published) == 0 {
1✔
NEW
295
                return "", true // no whole-census election on chain: nothing to resize
×
NEW
296
        }
×
297
        org, err := a.db.Organization(vp.OrgAddress)
1✔
298
        if err != nil {
1✔
NEW
299
                errors.ErrGenericInternalServerError.WithErr(err).Write(w)
×
NEW
300
                return "", false
×
NEW
301
        }
×
302
        orgSigner, err := account.OrganizationSigner(a.secret, org.Creator, org.Nonce)
1✔
303
        if err != nil {
1✔
NEW
304
                errors.ErrGenericInternalServerError.Withf("could not restore organization signer: %v", err).Write(w)
×
NEW
305
                return "", false
×
NEW
306
        }
×
307
        jobID, err := apicommon.NewJobID()
1✔
308
        if err != nil {
1✔
NEW
309
                errors.ErrGenericInternalServerError.WithErr(err).Write(w)
×
NEW
310
                return "", false
×
NEW
311
        }
×
312
        if err := a.db.CreateTxJob(jobID, db.JobTypeSetProcessCensus, org.Address); err != nil {
1✔
NEW
313
                errors.ErrGenericInternalServerError.WithErr(err).Write(w)
×
NEW
314
                return "", false
×
NEW
315
        }
×
316
        root := census.Published.Root
1✔
317
        uri := census.Published.URI
1✔
318
        size := uint64(census.Size)
1✔
319
        orgLock := a.orgTxLocks.lock(org.Address)
1✔
320
        if !a.enqueueTx(txTask{jobID: jobID, run: func() (*db.JobResult, error) {
2✔
321
                defer orgLock.Unlock()
1✔
322
                for i := range published {
2✔
323
                        tx, err := a.account.BuildSetProcessCensusTx(orgSigner.Address(), published[i].UpstreamID, root, uri, size)
1✔
324
                        if err != nil {
1✔
NEW
325
                                return nil, err
×
NEW
326
                        }
×
327
                        fundedTx, _, err := a.account.FundTransaction(tx, orgSigner.Address())
1✔
328
                        if err != nil {
1✔
NEW
329
                                return nil, err
×
NEW
330
                        }
×
331
                        stx, err := a.account.SignTransaction(fundedTx, orgSigner)
1✔
332
                        if err != nil {
1✔
NEW
333
                                return nil, err
×
NEW
334
                        }
×
335
                        if _, err := a.account.SubmitSignedTx(stx); err != nil {
1✔
NEW
336
                                return nil, err
×
NEW
337
                        }
×
338
                }
339
                return &db.JobResult{Status: string(db.JobStatusCompleted)}, nil
1✔
NEW
340
        }}) {
×
NEW
341
                orgLock.Unlock()
×
NEW
342
                if e := a.db.SetJobStatus(jobID, db.JobStatusFailed, nil, "tx queue full"); e != nil {
×
NEW
343
                        log.Warnw("could not mark job failed after full queue", "error", e)
×
NEW
344
                }
×
NEW
345
                errors.ErrTxQueueFull.Write(w)
×
NEW
346
                return "", false
×
347
        }
348
        return jobID, true
1✔
349
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc