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

vocdoni / saas-backend / 28178332752

25 Jun 2026 02:42PM UTC coverage: 62.083% (+0.1%) from 61.957%
28178332752

push

github

web-flow
feat(api)!: address processes by Mongo ObjectID in status/results/metadata (#551)

* feat(api)!: address processes by Mongo ObjectID in status/results/metadata

PUT /process/{processId}/status, GET /process/{processId}/results and
GET /process/{processId}/metadata parsed {processId} as the 64-hex on-chain
election id, while GET/PUT/DELETE /process/{processId} and .../publish parsed
it as the 24-hex Mongo ObjectID. The same path segment meant two different
identifiers, so e.g. GET /process/{onchainID} returned "invalid process ID".

Make all three look the process up by its ObjectID (a.db.Process) and use the
stored process.Address for the Vochain call, matching the rest of the process
API. results/status now require the process to be published (it has an Address).

The 64-hex on-chain id stays only where a voter needs it to sign/verify a vote:
POST /process/{processId}/vote, POST /process/{processId}/sign-info, and the
bundle sign/check bodies.

BREAKING CHANGE: status, results and metadata now take the 24-hex Mongo
ObjectID in {processId}, not the 64-hex on-chain election id.

* feat(api): accept Mongo ObjectID for process sign-info (keep on-chain id)

POST /process/{processId}/sign-info now resolves {processId} as the 24-hex Mongo
ObjectID (a.mainDB.Process) and uses the looked-up process.Address for the CSP
lookup and nullifier - consistent with status/results/metadata.

Exceptionally, and unlike those three, it ALSO still accepts the 64-hex on-chain
election id directly, to avoid breaking voter clients that already hold it (a
valid ObjectID is exactly 24 hex chars, so it never collides with the 64-hex id).

TestProcessReadProxies now asserts both forms return the same consumed address
and nullifier.

32 of 43 new or added lines in 3 files covered. (74.42%)

9816 of 15811 relevant lines covered (62.08%)

44.63 hits per line

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

38.8
/api/process_vote.go
1
package api
2

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

8
        "github.com/go-chi/chi/v5"
9
        "github.com/vocdoni/saas-backend/account"
10
        "github.com/vocdoni/saas-backend/api/apicommon"
11
        "github.com/vocdoni/saas-backend/db"
12
        "github.com/vocdoni/saas-backend/errors"
13
        "github.com/vocdoni/saas-backend/internal"
14
        "go.mongodb.org/mongo-driver/bson/primitive"
15
        "go.vocdoni.io/dvote/log"
16
        "go.vocdoni.io/proto/build/go/models"
17
        "google.golang.org/protobuf/proto"
18
)
19

20
// relayVoteHandler godoc
21
//
22
//        @Summary                Relay an already-signed vote to the Vochain
23
//        @Description        Relays a voter transaction that has already been signed by the voter to the
24
//        @Description        Vochain. The body carries a marshaled models.SignedTx whose inner Tx is a Vote
25
//        @Description        envelope; the target process is taken from that envelope, so no process id is
26
//        @Description        passed in the path. Public endpoint: no authentication is required. The request is
27
//        @Description        checked synchronously — the body must decode to a Vote envelope (else 400) for a
28
//        @Description        process the backend knows (else 404) — then enqueued for submission on a background
29
//        @Description        worker; the call returns 202 with a job id. The chain's acceptance or rejection of
30
//        @Description        the vote (proof, nullifier, election state) is decided when the worker submits it and
31
//        @Description        reported on the job: poll GET /jobs/{jobId} for the voteID on success, or a failure.
32
//        @Tags                        process
33
//        @Accept                        json
34
//        @Produce                json
35
//        @Param                        request        body                apicommon.RelayVoteRequest        true        "Signed vote transaction payload"
36
//        @Success                202                {object}        apicommon.EnqueuedResponse        "Job accepted; poll GET /jobs/{jobId}"
37
//        @Failure                400                {object}        errors.Error                                "Invalid input data"
38
//        @Failure                404                {object}        errors.Error                                "Process not found"
39
//        @Failure                500                {object}        errors.Error                                "Internal server error"
40
//        @Failure                503                {object}        errors.Error                                "Transaction queue is full"
41
//        @Router                        /vote [post]
42
func (a *API) relayVoteHandler(w http.ResponseWriter, r *http.Request) {
5✔
43
        var req apicommon.RelayVoteRequest
5✔
44
        if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
5✔
45
                errors.ErrMalformedBody.Write(w)
×
46
                return
×
47
        }
×
48
        if len(req.TxPayload) == 0 {
5✔
49
                errors.ErrMalformedBody.Withf("missing txPayload").Write(w)
×
50
                return
×
51
        }
×
52

53
        signedTx := &models.SignedTx{}
5✔
54
        if err := proto.Unmarshal(req.TxPayload, signedTx); err != nil {
5✔
55
                errors.ErrInvalidTxFormat.Withf("could not decode signed tx: %v", err).Write(w)
×
56
                return
×
57
        }
×
58
        innerTx := &models.Tx{}
5✔
59
        if err := proto.Unmarshal(signedTx.Tx, innerTx); err != nil {
5✔
60
                errors.ErrInvalidTxFormat.Withf("could not decode tx: %v", err).Write(w)
×
61
                return
×
62
        }
×
63

64
        vote := innerTx.GetVote()
5✔
65
        if vote == nil {
5✔
66
                errors.ErrInvalidTxFormat.With("not a vote tx").Write(w)
×
67
                return
×
68
        }
×
69
        // the target process is the one named in the signed vote envelope.
70
        pid := internal.HexBytes(vote.ProcessId)
5✔
71
        if len(pid) == 0 {
5✔
72
                errors.ErrInvalidTxFormat.With("vote has no process id").Write(w)
×
73
                return
×
74
        }
×
75

76
        // ensure we manage this process
77
        process, err := a.db.ProcessByAddress(pid)
5✔
78
        if err != nil {
5✔
79
                if err == db.ErrNotFound {
×
80
                        errors.ErrProcessNotFound.Write(w)
×
81
                        return
×
82
                }
×
83
                errors.ErrGenericInternalServerError.WithErr(err).Write(w)
×
84
                return
×
85
        }
86

87
        // submit + confirm on the worker pool; the vote nullifier (voteID) is recorded on the
88
        // job. The structural checks above ran synchronously (a malformed vote got a 400, an
89
        // unknown process a 404); the chain's acceptance of the vote is decided here, async, and
90
        // surfaced on the job.
91
        jobID, err := apicommon.NewJobID()
5✔
92
        if err != nil {
5✔
93
                errors.ErrGenericInternalServerError.WithErr(err).Write(w)
×
94
                return
×
95
        }
×
96
        if err := a.db.CreateTxJob(jobID, db.JobTypeRelayVote, process.OrgAddress); err != nil {
5✔
97
                errors.ErrGenericInternalServerError.WithErr(err).Write(w)
×
98
                return
×
99
        }
×
100
        payload := req.TxPayload
5✔
101
        if !a.enqueueTx(txTask{jobID: jobID, run: func() (*db.JobResult, error) {
10✔
102
                voteID, err := a.account.SubmitSignedTx(payload)
5✔
103
                if err != nil {
5✔
104
                        return nil, err
×
105
                }
×
106
                return &db.JobResult{VoteID: internal.HexBytes(voteID)}, nil
5✔
107
        }}) {
×
108
                // full queue: mark the job failed so it is not orphaned pending.
×
109
                if e := a.db.SetJobStatus(jobID, db.JobStatusFailed, nil, "tx queue full"); e != nil {
×
110
                        log.Warnw("could not mark job failed after full queue", "error", e)
×
111
                }
×
112
                errors.ErrTxQueueFull.Write(w)
×
113
                return
×
114
        }
115

116
        apicommon.HTTPWriteJSONStatus(w, http.StatusAccepted, &apicommon.EnqueuedResponse{JobID: jobID})
5✔
117
}
118

119
// setProcessStatusHandler godoc
120
//
121
//        @Summary                Change an on-chain election status
122
//        @Description        Changes the status of an on-chain election (ready|paused|ended|canceled). The
123
//        @Description        backend builds a SET_PROCESS_STATUS transaction, funds and signs it with the
124
//        @Description        organization signer synchronously, then submits and confirms it on a background
125
//        @Description        worker; the call returns 202 with a job id. Poll GET /jobs/{jobId} for the result.
126
//        @Description        Requires Manager/Admin role of the organization that owns the process.
127
//        @Description
128
//        @Description        Also callable with a scoped API key (scope: `voting:write`).
129
//        @Tags                        process
130
//        @Accept                        json
131
//        @Produce                json
132
//        @Security                BearerAuth
133
//        @Param                        processId        path                string                                                                true        "24-hex ProcessID"
134
//        @Param                        request                body                apicommon.SetProcessStatusRequest        true        "New process status"
135
//        @Success                202                        {object}        apicommon.EnqueuedResponse                        "Job accepted; poll GET /jobs/{jobId}"
136
//        @Failure                400                        {object}        errors.Error                                                "Invalid input data"
137
//        @Failure                401                        {object}        errors.Error                                                "Unauthorized"
138
//        @Failure                404                        {object}        errors.Error                                                "Process not found"
139
//        @Failure                500                        {object}        errors.Error                                                "Internal server error"
140
//        @Failure                503                        {object}        errors.Error                                                "Transaction queue is full"
141
//        @Router                        /process/{processId}/status [put]
142
func (a *API) setProcessStatusHandler(w http.ResponseWriter, r *http.Request) {
5✔
143
        objID, err := primitive.ObjectIDFromHex(chi.URLParam(r, "processId"))
5✔
144
        if err != nil {
5✔
145
                errors.ErrMalformedURLParam.Withf("invalid process id").Write(w)
×
146
                return
×
147
        }
×
148

149
        user, ok := apicommon.UserFromContext(r.Context())
5✔
150
        if !ok {
5✔
151
                errors.ErrUnauthorized.Write(w)
×
152
                return
×
153
        }
×
154

155
        var req apicommon.SetProcessStatusRequest
5✔
156
        if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
5✔
157
                errors.ErrMalformedBody.Write(w)
×
158
                return
×
159
        }
×
160

161
        var status models.ProcessStatus
5✔
162
        switch strings.ToLower(req.Status) {
5✔
163
        case "ready":
1✔
164
                status = models.ProcessStatus_READY
1✔
165
        case "paused":
1✔
166
                status = models.ProcessStatus_PAUSED
1✔
167
        case "ended":
3✔
168
                status = models.ProcessStatus_ENDED
3✔
169
        case "canceled":
×
170
                status = models.ProcessStatus_CANCELED
×
171
        default:
×
172
                errors.ErrMalformedBody.With("invalid status").Write(w)
×
173
                return
×
174
        }
175

176
        process, err := a.db.Process(objID)
5✔
177
        if err != nil {
5✔
178
                if err == db.ErrNotFound {
×
179
                        errors.ErrProcessNotFound.Write(w)
×
180
                        return
×
181
                }
×
182
                errors.ErrGenericInternalServerError.WithErr(err).Write(w)
×
183
                return
×
184
        }
185
        // only a published process has an on-chain election whose status can be changed.
186
        if len(process.Address) == 0 {
5✔
NEW
187
                errors.ErrProcessNotFound.Withf("process not published").Write(w)
×
NEW
188
                return
×
NEW
189
        }
×
190

191
        // permission: Manager or Admin of the owning organization
192
        if !user.HasRoleFor(process.OrgAddress, db.ManagerRole) && !user.HasRoleFor(process.OrgAddress, db.AdminRole) {
5✔
193
                errors.ErrUnauthorized.Withf("user is not admin or manager of the organization that owns this process").Write(w)
×
194
                return
×
195
        }
×
196

197
        org, err := a.db.Organization(process.OrgAddress)
5✔
198
        if err != nil {
5✔
199
                if err == db.ErrNotFound {
×
200
                        errors.ErrOrganizationNotFound.Write(w)
×
201
                        return
×
202
                }
×
203
                errors.ErrGenericInternalServerError.WithErr(err).Write(w)
×
204
                return
×
205
        }
206

207
        orgSigner, err := account.OrganizationSigner(a.secret, org.Creator, org.Nonce)
5✔
208
        if err != nil {
5✔
209
                errors.ErrGenericInternalServerError.Withf("could not restore organization signer: %v", err).Write(w)
×
210
                return
×
211
        }
×
212

213
        // serialize build->sign->submit per organization so a concurrent status change or
214
        // publish for the same org cannot read the same account nonce and sign a conflicting
215
        // tx. The worker releases the lock after submit (held across the async hand-off);
216
        // every synchronous failure below releases it via the deferred unlock.
217
        orgLock := a.orgTxLocks.lock(org.Address)
5✔
218
        lockHeld := true
5✔
219
        defer func() {
10✔
220
                if lockHeld {
5✔
221
                        orgLock.Unlock()
×
222
                }
×
223
        }()
224

225
        tx, err := a.account.BuildSetProcessStatusTx(orgSigner.Address(), process.Address.Bytes(), status)
5✔
226
        if err != nil {
5✔
227
                errors.ErrVochainRequestFailed.WithErr(err).Write(w)
×
228
                return
×
229
        }
×
230

231
        // fund
232
        fundedTx, txType, err := a.account.FundTransaction(tx, orgSigner.Address())
5✔
233
        if err != nil {
5✔
234
                if apiErr, ok := err.(errors.Error); ok {
×
235
                        apiErr.Write(w)
×
236
                        return
×
237
                }
×
238
                errors.ErrVochainRequestFailed.WithErr(err).Write(w)
×
239
                return
×
240
        }
241
        if txType == nil || *txType != models.TxType_SET_PROCESS_STATUS {
5✔
242
                errors.ErrInvalidTxFormat.With("unexpected tx type for status change").Write(w)
×
243
                return
×
244
        }
×
245

246
        // quota / permission (same engine as the /transactions and publish paths)
247
        if hasPermission, err := a.subscriptions.HasTxPermission(fundedTx, *txType, org, user); !hasPermission || err != nil {
5✔
248
                errors.ErrUnauthorized.Withf("user does not have permission to change process status: %v", err).Write(w)
×
249
                return
×
250
        }
×
251

252
        // sign with the organization signer
253
        stx, err := a.account.SignTransaction(fundedTx, orgSigner)
5✔
254
        if err != nil {
5✔
255
                errors.ErrGenericInternalServerError.Withf("could not sign status tx: %v", err).Write(w)
×
256
                return
×
257
        }
×
258

259
        // submit + wait on the worker pool; on success persist the new cached status
260
        // (canonical uppercase enum name e.g. "PAUSED").
261
        newStatus := strings.ToUpper(req.Status)
5✔
262
        jobID, err := apicommon.NewJobID()
5✔
263
        if err != nil {
5✔
264
                errors.ErrGenericInternalServerError.WithErr(err).Write(w)
×
265
                return
×
266
        }
×
267
        if err := a.db.CreateTxJob(jobID, db.JobTypeSetProcessStatus, process.OrgAddress); err != nil {
5✔
268
                errors.ErrGenericInternalServerError.WithErr(err).Write(w)
×
269
                return
×
270
        }
×
271
        if !a.enqueueTx(txTask{jobID: jobID, run: func() (*db.JobResult, error) {
10✔
272
                defer orgLock.Unlock()
5✔
273
                if _, err := a.account.SubmitSignedTx(stx); err != nil {
5✔
274
                        return nil, err
×
275
                }
×
276
                process.Status = newStatus
5✔
277
                if _, err := a.db.SetProcess(process); err != nil {
5✔
278
                        return nil, err
×
279
                }
×
280
                return &db.JobResult{Status: newStatus}, nil
5✔
281
        }}) {
×
282
                // full queue: mark the job failed so it is not orphaned pending; the deferred
×
283
                // unlock fires on return.
×
284
                if e := a.db.SetJobStatus(jobID, db.JobStatusFailed, nil, "tx queue full"); e != nil {
×
285
                        log.Warnw("could not mark job failed after full queue", "error", e)
×
286
                }
×
287
                errors.ErrTxQueueFull.Write(w)
×
288
                return
×
289
        }
290
        lockHeld = false
5✔
291

5✔
292
        apicommon.HTTPWriteJSONStatus(w, http.StatusAccepted, &apicommon.EnqueuedResponse{JobID: jobID})
5✔
293
}
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