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

vocdoni / saas-backend / 28449634629

30 Jun 2026 01:53PM UTC coverage: 62.788% (+0.04%) from 62.75%
28449634629

Pull #565

github

emmdim
fix(api): enforce integrator process quota on the /transactions path

PR #557 added an `if !managed(org)` skip in HasTxPermission, deferring the
managed-org process-count check to the integrator's aggregate ManagedProcesses
quota (ReserveManagedPublish) "at publish time". But that reservation was only
wired into publishProcessHandler (/process/{id}/publish); the remote-signer
/transactions path (signTxHandler) — the primary SDK flow — was left calling
HasTxPermission with the check skipped and never reserving.

Result: managed orgs could mint unlimited non-test elections via /transactions,
bypassing the integrator cap, and /integrator under-reported ManagedProcesses.

Extract the reserve logic from publishProcessHandler into a shared
reserveManagedProcessSlot helper and call it from both NEW_PROCESS entry points
so they cannot drift apart again. signTxHandler rolls the reservation back if
signing fails, mirroring the publish path.

Adds TestIntegratorProcessQuotaViaTransactions covering the counter increment,
the aggregate cap, and the test-sized exemption on /transactions.
Pull Request #565: fix(api): enforce integrator process quota on the /transactions path

36 of 52 new or added lines in 2 files covered. (69.23%)

10301 of 16406 relevant lines covered (62.79%)

44.98 hits per line

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

56.13
/api/process.go
1
package api
2

3
import (
4
        "context"
5
        "encoding/json"
6
        "io"
7
        "net/http"
8
        "strings"
9
        "time"
10

11
        "github.com/ethereum/go-ethereum/common"
12
        "github.com/go-chi/chi/v5"
13
        "github.com/vocdoni/saas-backend/account"
14
        "github.com/vocdoni/saas-backend/api/apicommon"
15
        "github.com/vocdoni/saas-backend/db"
16
        "github.com/vocdoni/saas-backend/errors"
17
        "github.com/vocdoni/saas-backend/internal"
18
        "github.com/vocdoni/saas-backend/subscriptions"
19
        "go.mongodb.org/mongo-driver/bson/primitive"
20
        dvoteapi "go.vocdoni.io/dvote/api"
21
        "go.vocdoni.io/dvote/log"
22
        "go.vocdoni.io/proto/build/go/models"
23
)
24

25
// createProcessHandler godoc
26
//
27
//        @Summary                Create a new voting process
28
//        @Description        Create a new voting process (draft). Requires Manager/Admin role of the owning organization. The
29
//        @Description        request must reference the organization via either a `censusId` or an explicit `orgAddress`.
30
//        @Description        Creating a draft consumes the plan's draft permission.
31
//        @Description
32
//        @Description        The optional `electionParams` field carries the on-chain election definition (questions, dates,
33
//        @Description        vote/election types) used later to publish the draft on chain via POST /process/{processId}/publish.
34
//        @Description        It is additive: omitting it keeps the legacy draft behavior.
35
//        @Description
36
//        @Description        Also callable with a scoped API key (scope: `voting:write`).
37
//        @Tags                        process
38
//        @Accept                        json
39
//        @Produce                json
40
//        @Security                BearerAuth
41
//        @Param                        request        body                apicommon.CreateProcessRequest        true        "Process creation information (optionally with electionParams)"
42
//        @Success                200                {object}        primitive.ObjectID                                "Bare JSON string: 24-hex draft ProcessID, e.g. 507f1f77bcf86cd799439011"
43
//        @Failure                400                {object}        errors.Error                                        "Invalid input data"
44
//        @Failure                401                {object}        errors.Error                                        "Unauthorized"
45
//        @Failure                403                {object}        errors.Error                                        "Plan does not allow creating more drafts"
46
//        @Failure                404                {object}        errors.Error                                        "Published census not found"
47
//        @Failure                500                {object}        errors.Error                                        "Internal server error"
48
//        @Router                        /process [post]
49
func (a *API) createProcessHandler(w http.ResponseWriter, r *http.Request) {
15✔
50
        // parse the process info from the request body
15✔
51
        processInfo := &apicommon.CreateProcessRequest{}
15✔
52
        if err := json.NewDecoder(r.Body).Decode(&processInfo); err != nil {
15✔
53
                errors.ErrMalformedBody.Write(w)
×
54
                return
×
55
        }
×
56

57
        // get the user from the request context
58
        user, ok := apicommon.UserFromContext(r.Context())
15✔
59
        if !ok {
15✔
60
                errors.ErrUnauthorized.Write(w)
×
61
                return
×
62
        }
×
63

64
        // if it's a draft process
65
        if processInfo.Address.Equals(nil) && processInfo.OrgAddress == (common.Address{}) {
17✔
66
                errors.ErrMalformedBody.Withf("draft processes must provide an org address").Write(w)
2✔
67
                return
2✔
68
        }
2✔
69

70
        // Create or update the process
71
        process := &db.Process{
13✔
72
                Metadata:       processInfo.Metadata,
13✔
73
                ElectionParams: processInfo.ElectionParams,
13✔
74
        }
13✔
75

13✔
76
        var orgAddress common.Address
13✔
77
        if processInfo.CensusID != nil {
25✔
78
                var err error
12✔
79
                census, err := a.db.Census(processInfo.CensusID.String())
12✔
80
                if err != nil {
12✔
81
                        if err == db.ErrNotFound {
×
82
                                errors.ErrMalformedURLParam.Withf("invalid census provided").Write(w)
×
83
                                return
×
84
                        }
×
85
                        errors.ErrGenericInternalServerError.WithErr(err).Write(w)
×
86
                        return
×
87
                }
88
                process.Census = *census
12✔
89
                orgAddress = census.OrgAddress
12✔
90
        } else if processInfo.OrgAddress != (common.Address{}) {
2✔
91
                orgAddress = processInfo.OrgAddress
1✔
92
        } else {
1✔
93
                errors.ErrMalformedBody.Withf("either census ID or organization address must be provided").Write(w)
×
94
                return
×
95
        }
×
96

97
        // check the user has the necessary permissions
98
        if !user.HasRoleFor(orgAddress, db.ManagerRole) && !user.HasRoleFor(orgAddress, db.AdminRole) {
13✔
99
                errors.ErrUnauthorized.Withf("user is not admin of organization").Write(w)
×
100
                return
×
101
        }
×
102

103
        process.OrgAddress = orgAddress
13✔
104
        if !processInfo.Address.Equals(nil) {
15✔
105
                process.Address = processInfo.Address
2✔
106
        }
2✔
107

108
        // if it's a new draft process
109
        if process.Address.Equals(nil) && process.ID == primitive.NilObjectID {
24✔
110
                if err := a.subscriptions.OrgHasPermission(process.OrgAddress, subscriptions.CreateDraft); err != nil {
13✔
111
                        if apierr, ok := err.(errors.Error); ok {
4✔
112
                                apierr.Write(w)
2✔
113
                                return
2✔
114
                        }
2✔
115
                        errors.ErrGenericInternalServerError.WithErr(err).Write(w)
×
116
                        return
×
117
                }
118
        }
119

120
        processID, err := a.db.SetProcess(process)
11✔
121
        if err != nil {
11✔
122
                errors.ErrGenericInternalServerError.WithErr(err).Write(w)
×
123
                return
×
124
        }
×
125

126
        apicommon.HTTPWriteJSON(w, processID)
11✔
127
}
128

129
// updateProcessHandler godoc
130
//
131
//        @Summary                Update an existing voting process
132
//        @Description        Update an existing draft voting process. Requires Manager/Admin role of the owning organization.
133
//        @Description        Only drafts that have not yet been published on chain can be updated; updating a published process
134
//        @Description        returns a 409. The optional `electionParams` field can be set or replaced here (additive), same as on
135
//        @Description        create.
136
//        @Tags                        process
137
//        @Accept                        json
138
//        @Produce                json
139
//        @Security                BearerAuth
140
//        @Param                        processId        path                string                                                        true        "24-hex draft ProcessID"
141
//        @Param                        request                body                apicommon.UpdateProcessRequest        true        "Process update information (optionally with electionParams)"
142
//        @Success                200                        {string}        string                                                        "OK"
143
//        @Failure                400                        {object}        errors.Error                                        "Invalid input data"
144
//        @Failure                401                        {object}        errors.Error                                        "Unauthorized"
145
//        @Failure                404                        {object}        errors.Error                                        "Process not found"
146
//        @Failure                409                        {object}        errors.Error                                        "Process already published and not in draft mode"
147
//        @Failure                500                        {object}        errors.Error                                        "Internal server error"
148
//        @Router                        /process/{processId} [put]
149
func (a *API) updateProcessHandler(w http.ResponseWriter, r *http.Request) {
2✔
150
        processID := chi.URLParam(r, "processId")
2✔
151
        if processID == "" {
2✔
152
                errors.ErrMalformedURLParam.Withf("missing process ID").Write(w)
×
153
                return
×
154
        }
×
155
        parsedID, err := primitive.ObjectIDFromHex(processID)
2✔
156
        if err != nil {
2✔
157
                errors.ErrMalformedURLParam.Withf("invalid process ID").Write(w)
×
158
                return
×
159
        }
×
160

161
        // parse the process info from the request body
162
        processInfo := &apicommon.UpdateProcessRequest{}
2✔
163
        if err := json.NewDecoder(r.Body).Decode(&processInfo); err != nil {
2✔
164
                errors.ErrMalformedBody.Write(w)
×
165
                return
×
166
        }
×
167

168
        // get the user from the request context
169
        user, ok := apicommon.UserFromContext(r.Context())
2✔
170
        if !ok {
2✔
171
                errors.ErrUnauthorized.Write(w)
×
172
                return
×
173
        }
×
174

175
        existingProcess, err := a.db.Process(parsedID)
2✔
176
        if err != nil {
2✔
177
                if err == db.ErrNotFound {
×
178
                        errors.ErrProcessNotFound.Write(w)
×
179
                        return
×
180
                }
×
181
                errors.ErrGenericInternalServerError.WithErr(err).Write(w)
×
182
                return
×
183
        }
184

185
        // check if it's a draft process and can be overwritten
186
        if !existingProcess.Address.Equals(nil) {
3✔
187
                errors.ErrDuplicateConflict.Withf("process already exists and is not in draft mode").Write(w)
1✔
188
                return
1✔
189
        }
1✔
190

191
        // check the user has the necessary permissions
192
        if !user.HasRoleFor(existingProcess.OrgAddress, db.ManagerRole) &&
1✔
193
                !user.HasRoleFor(existingProcess.OrgAddress, db.AdminRole) {
1✔
194
                errors.ErrUnauthorized.Withf("user is not admin or manager of the organization that owns this process").Write(w)
×
195
                return
×
196
        }
×
197

198
        var census *db.Census
1✔
199
        if !processInfo.CensusID.Equals(nil) {
1✔
200
                census, err = a.db.Census(processInfo.CensusID.String())
×
201
                if err != nil {
×
202
                        if err == db.ErrNotFound {
×
203
                                errors.ErrMalformedURLParam.Withf("census not found").Write(w)
×
204
                                return
×
205
                        }
×
206
                        errors.ErrGenericInternalServerError.WithErr(err).Write(w)
×
207
                        return
×
208
                }
209
                existingProcess.Census = *census
×
210
        }
211

212
        if len(processInfo.Metadata) > 0 {
2✔
213
                existingProcess.Metadata = processInfo.Metadata
1✔
214
        }
1✔
215

216
        if processInfo.ElectionParams != nil {
1✔
217
                existingProcess.ElectionParams = processInfo.ElectionParams
×
218
        }
×
219

220
        if !processInfo.Address.Equals(nil) {
2✔
221
                existingProcess.Address = processInfo.Address
1✔
222
        }
1✔
223

224
        _, err = a.db.SetProcess(existingProcess)
1✔
225
        if err != nil {
1✔
226
                errors.ErrGenericInternalServerError.WithErr(err).Write(w)
×
227
                return
×
228
        }
×
229

230
        apicommon.HTTPWriteOK(w)
1✔
231
}
232

233
// processInfoHandler godoc
234
//
235
//        @Summary                Get process information
236
//        @Description        Retrieve voting process information by ID. Returns process details including census and metadata.
237
//        @Description        For encrypted (secretUntilTheEnd) elections the response additionally carries the election's
238
//        @Description        encryption public keys in `encryptionKeys` (one per keykeeper) once they are published on chain;
239
//        @Description        the field is absent for non-encrypted elections and before the keys are published.
240
//        @Tags                        process
241
//        @Accept                        json
242
//        @Produce                json
243
//        @Param                        processId        path                string        true        "Process ID"
244
//        @Success                200                        {object}        apicommon.ProcessInfo
245
//        @Failure                400                        {object}        errors.Error        "Invalid process ID"
246
//        @Failure                404                        {object}        errors.Error        "Process not found"
247
//        @Failure                500                        {object}        errors.Error        "Internal server error"
248
//        @Router                        /process/{processId} [get]
249
func (a *API) processInfoHandler(w http.ResponseWriter, r *http.Request) {
17✔
250
        processID := chi.URLParam(r, "processId")
17✔
251
        if len(processID) == 0 {
17✔
252
                errors.ErrMalformedURLParam.Withf("missing process ID").Write(w)
×
253
                return
×
254
        }
×
255
        parsedID, err := primitive.ObjectIDFromHex(processID)
17✔
256
        if err != nil {
18✔
257
                errors.ErrMalformedURLParam.Withf("invalid process ID").Write(w)
1✔
258
                return
1✔
259
        }
1✔
260

261
        process, err := a.db.Process(parsedID)
16✔
262
        if err != nil {
17✔
263
                if err == db.ErrNotFound {
2✔
264
                        errors.ErrProcessNotFound.Write(w)
1✔
265
                        return
1✔
266
                }
1✔
267
                errors.ErrGenericInternalServerError.WithErr(err).Write(w)
×
268
                return
×
269
        }
270

271
        // resolve the canonical metadata from its reference (best-effort): the Vochain only
272
        // resolves ipfs:// pointers, so the https /storage pointer this service publishes is
273
        // resolved here instead.
274
        if md := a.resolveProcessMetadata(r.Context(), process); md != nil {
18✔
275
                process.Metadata = md
3✔
276
        }
3✔
277

278
        // for encrypted (secretUntilTheEnd) elections, expose the encryption public keys the
279
        // voter needs to encrypt their ballot. Gated and cached so the common (non-encrypted)
280
        // read path never touches the chain.
281
        process.EncryptionKeys = a.resolveProcessEncryptionKeys(process)
15✔
282

15✔
283
        apicommon.HTTPWriteJSON(w, &apicommon.ProcessInfo{
15✔
284
                Process: process,
15✔
285
                ChainID: a.account.ChainID(),
15✔
286
        })
15✔
287
}
288

289
// resolveProcessEncryptionKeys returns the encryption public keys for an encrypted
290
// election, fetching them from the Vochain only when needed and caching them on the
291
// process once published. It is a no-op (returns nil, no chain call) for non-encrypted
292
// elections, unpublished drafts, and processes whose keys are already cached are served
293
// from the cache. The result is cached only when non-empty: between an election's creation
294
// and the keykeepers publishing its keys the node returns an empty set, and a later read
295
// must still pick them up.
296
func (a *API) resolveProcessEncryptionKeys(p *db.Process) []db.EncryptionKey {
15✔
297
        // gate: only encrypted elections have encryption keys. Reading the stored election type
15✔
298
        // keeps every other process on a chain-free path.
15✔
299
        if p.ElectionParams == nil || !p.ElectionParams.ElectionType.SecretUntilTheEnd {
29✔
300
                return nil
14✔
301
        }
14✔
302
        // nothing on chain yet (draft) — no keys to fetch
303
        if p.Address.Equals(nil) {
1✔
304
                return nil
×
305
        }
×
306
        // immutable once published: serve the cached keys without a chain round-trip
307
        if len(p.EncryptionKeys) > 0 {
1✔
308
                return p.EncryptionKeys
×
309
        }
×
310
        keys, err := a.account.ElectionEncryptionKeys(p.Address.Bytes())
1✔
311
        if err != nil {
1✔
312
                log.Warnw("encryption keys: election keys fetch failed", "process", p.Address.String(), "error", err)
×
313
                return nil
×
314
        }
×
315
        // not published yet — do not cache an empty set so a later read still resolves them
316
        if len(keys) == 0 {
1✔
317
                return nil
×
318
        }
×
319
        if err := a.db.SetProcessEncryptionKeys(p.ID, keys); err != nil {
1✔
320
                log.Warnw("encryption keys: could not persist keys", "process", p.Address.String(), "error", err)
×
321
        }
×
322
        return keys
1✔
323
}
324

325
// metadataObjectUserID is the object-storage owner recorded for server-generated
326
// metadata documents cached on read.
327
const metadataObjectUserID = "system"
328

329
// resolveProcessMetadata returns the canonical metadata for a published process,
330
// consulting process.MetadataURL. Returns nil (caller keeps the stored value) for
331
// drafts or on any error. Persists the reference inline only when it changed.
332
func (a *API) resolveProcessMetadata(ctx context.Context, p *db.Process) map[string]any {
15✔
333
        if p.Address.Equals(nil) { // draft, nothing on chain
23✔
334
                return nil
8✔
335
        }
8✔
336
        var election *dvoteapi.Election
7✔
337
        changed := false
7✔
338
        // bootstrap the reference from the on-chain pointer if we don't have one yet
7✔
339
        if p.MetadataURL == "" {
11✔
340
                el, err := a.account.Election(p.Address.Bytes())
4✔
341
                if err != nil {
6✔
342
                        log.Warnw("metadata: election fetch failed", "process", p.Address.String(), "error", err)
2✔
343
                        return nil
2✔
344
                }
2✔
345
                election, p.MetadataURL, changed = el, el.MetadataURL, true
2✔
346
        }
347

348
        // resolve the document. Branches set m on success and leave it nil on failure; no
349
        // early return, so a bootstrapped reference is still persisted below (avoids
350
        // re-deriving it from the chain on every read).
351
        var m map[string]any
5✔
352
        name, isLocal := a.objectStorage.LocalName(p.MetadataURL)
5✔
353
        switch {
5✔
354
        case isLocal:
2✔
355
                // our own object storage — resolve locally; treat a corrupt/non-JSON object as a
2✔
356
                // resolution failure (m stays nil) so the stored metadata is kept.
2✔
357
                if obj, err := a.objectStorage.GetByName(name); err == nil {
4✔
358
                        if json.Unmarshal(obj.Data, &m) != nil {
2✔
359
                                m = nil
×
360
                        }
×
361
                }
362
        case strings.HasPrefix(p.MetadataURL, "ipfs://"):
×
363
                // the Vochain resolves ipfs; reuse the election if already fetched
×
364
                if election == nil {
×
365
                        if el, err := a.account.Election(p.Address.Bytes()); err == nil {
×
366
                                election = el
×
367
                        }
×
368
                }
369
                if election != nil {
×
370
                        if mm, ok := election.Metadata.(map[string]any); ok && len(mm) > 0 {
×
371
                                m = mm
×
372
                        }
×
373
                }
374
        case strings.HasPrefix(p.MetadataURL, "http://"), strings.HasPrefix(p.MetadataURL, "https://"):
1✔
375
                m = fetchExternalMetadata(ctx, p.MetadataURL)
1✔
376
        default:
2✔
377
                // unrecognized scheme — leave m nil; a bootstrapped reference is still persisted below
378
        }
379

380
        // metadata is immutable (a change is a republish through this API), so cache any
381
        // remotely-resolved document locally and promote the reference — later reads then
382
        // resolve from our own storage with no Vochain or external round-trip.
383
        if m != nil && !isLocal {
6✔
384
                if b, err := json.Marshal(m); err == nil {
2✔
385
                        if stored, err := a.objectStorage.PutJSON(b, metadataObjectUserID); err == nil {
2✔
386
                                p.MetadataURL, changed = a.objectStorage.LocalURL(stored), true
1✔
387
                        }
1✔
388
                }
389
        }
390
        // persist a learned/promoted reference with a targeted update (not a full rewrite) so
391
        // a concurrent change to other process fields is not clobbered by this read path. This
392
        // runs even when resolution failed, so a bootstrapped reference is stored once.
393
        if changed {
8✔
394
                if err := a.db.SetProcessMetadataURL(p.ID, p.MetadataURL); err != nil {
3✔
395
                        log.Warnw("metadata: could not persist metadataURL", "process", p.Address.String(), "error", err)
×
396
                }
×
397
        }
398
        return m
5✔
399
}
400

401
// fetchExternalMetadata best-effort downloads and JSON-decodes a metadata document from
402
// an external http(s) reference (one that does not point at our own object storage).
403
// Returns nil on any failure.
404
func fetchExternalMetadata(ctx context.Context, url string) map[string]any {
4✔
405
        ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
4✔
406
        defer cancel()
4✔
407
        req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
4✔
408
        if err != nil {
4✔
409
                return nil
×
410
        }
×
411
        resp, err := http.DefaultClient.Do(req)
4✔
412
        if err != nil {
4✔
413
                return nil
×
414
        }
×
415
        defer func() { _ = resp.Body.Close() }()
8✔
416
        if resp.StatusCode != http.StatusOK {
5✔
417
                return nil
1✔
418
        }
1✔
419
        var m map[string]any
3✔
420
        if json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&m) != nil { // 1 MiB cap
4✔
421
                return nil
1✔
422
        }
1✔
423
        return m
2✔
424
}
425

426
// organizationListProcessDraftsHandler godoc
427
//
428
//        @Summary                Get paginated list of process drafts
429
//        @Description        Returns a list of voting process drafts.
430
//        @Tags                        process
431
//        @Accept                        json
432
//        @Produce                json
433
//        @Success                200        {object}        apicommon.ListOrganizationProcesses
434
//        @Failure                404        {object}        errors.Error        "Process not found"
435
//        @Failure                500        {object}        errors.Error        "Internal server error"
436
//        @Router                        /organizations/{address}/processes/drafts [get]
437
func (a *API) organizationListProcessDraftsHandler(w http.ResponseWriter, r *http.Request) {
4✔
438
        // get the organization info from the request context
4✔
439
        org, _, ok := a.organizationFromRequest(r)
4✔
440
        if !ok {
4✔
441
                errors.ErrNoOrganizationProvided.Write(w)
×
442
                return
×
443
        }
×
444
        // get the user from the request context
445
        user, ok := apicommon.UserFromContext(r.Context())
4✔
446
        if !ok {
4✔
447
                errors.ErrUnauthorized.Write(w)
×
448
                return
×
449
        }
×
450
        // check the user has the necessary permissions
451
        if !user.HasRoleFor(org.Address, db.ManagerRole) && !user.HasRoleFor(org.Address, db.AdminRole) {
4✔
452
                errors.ErrUnauthorized.Withf("user is not admin of organization").Write(w)
×
453
                return
×
454
        }
×
455

456
        params, err := parsePaginationParams(r.URL.Query().Get(ParamPage), r.URL.Query().Get(ParamLimit))
4✔
457
        if err != nil {
4✔
458
                errors.ErrMalformedURLParam.WithErr(err).Write(w)
×
459
                return
×
460
        }
×
461

462
        // retrieve the orgMembers with pagination
463
        totalItems, processes, err := a.db.ListProcesses(org.Address, params.Page, params.Limit, db.DraftOnly)
4✔
464
        if err != nil {
4✔
465
                errors.ErrGenericInternalServerError.Withf("could not get processes: %v", err).Write(w)
×
466
                return
×
467
        }
×
468
        pagination, err := calculatePagination(params.Page, params.Limit, totalItems)
4✔
469
        if err != nil {
4✔
470
                errors.ErrMalformedURLParam.WithErr(err).Write(w)
×
471
                return
×
472
        }
×
473

474
        apicommon.HTTPWriteJSON(w, &apicommon.ListOrganizationProcesses{
4✔
475
                Pagination: pagination,
4✔
476
                Processes:  processes,
4✔
477
        })
4✔
478
}
479

480
// deleteProcessHandler godoc
481
//
482
//        @Summary                Delete a draft voting process
483
//        @Description        Delete a draft voting process. Only unpublished drafts can be deleted; a process
484
//        @Description        already published on-chain cannot be removed. Requires Manager/Admin role of the
485
//        @Description        organization that owns the process.
486
//        @Description
487
//        @Description        Also callable with a scoped API key (scope: `voting:write`).
488
//        @Tags                        process
489
//        @Accept                        json
490
//        @Produce                json
491
//        @Security                BearerAuth
492
//        @Param                        processId        path                string                        true        "Process ID"
493
//        @Success                200                        {string}        string                        "OK"
494
//        @Failure                400                        {object}        errors.Error        "Invalid process ID"
495
//        @Failure                401                        {object}        errors.Error        "Unauthorized"
496
//        @Failure                404                        {object}        errors.Error        "Process not found"
497
//        @Failure                409                        {object}        errors.Error        "Process already published and not in draft mode"
498
//        @Failure                500                        {object}        errors.Error        "Internal server error"
499
//        @Router                        /process/{processId} [delete]
500
func (a *API) deleteProcessHandler(w http.ResponseWriter, r *http.Request) {
3✔
501
        processID := chi.URLParam(r, "processId")
3✔
502
        if processID == "" {
3✔
503
                errors.ErrMalformedURLParam.Withf("missing process ID").Write(w)
×
504
                return
×
505
        }
×
506
        parsedID, err := primitive.ObjectIDFromHex(processID)
3✔
507
        if err != nil {
3✔
508
                errors.ErrMalformedURLParam.Withf("invalid process ID").Write(w)
×
509
                return
×
510
        }
×
511

512
        // get the user from the request context
513
        user, ok := apicommon.UserFromContext(r.Context())
3✔
514
        if !ok {
3✔
515
                errors.ErrUnauthorized.Write(w)
×
516
                return
×
517
        }
×
518

519
        existingProcess, err := a.db.Process(parsedID)
3✔
520
        if err != nil {
3✔
521
                if err == db.ErrNotFound {
×
522
                        errors.ErrProcessNotFound.Withf("process not found").Write(w)
×
523
                        return
×
524
                }
×
525
                errors.ErrGenericInternalServerError.WithErr(err).Write(w)
×
526
                return
×
527
        }
528

529
        // only draft processes can be deleted; a published process has a non-nil
530
        // on-chain address and lives on the Vochain, where it cannot be removed.
531
        if !existingProcess.Address.Equals(nil) {
4✔
532
                errors.ErrDuplicateConflict.Withf("process already published and not in draft mode").Write(w)
1✔
533
                return
1✔
534
        }
1✔
535

536
        // check the user has the necessary permissions
537
        if !user.HasRoleFor(existingProcess.OrgAddress, db.ManagerRole) &&
2✔
538
                !user.HasRoleFor(existingProcess.OrgAddress, db.AdminRole) {
2✔
539
                errors.ErrUnauthorized.Withf("user is not admin or manager of the organization that owns this process").Write(w)
×
540
                return
×
541
        }
×
542

543
        err = a.db.DelProcess(parsedID)
2✔
544
        if err != nil {
2✔
545
                errors.ErrGenericInternalServerError.WithErr(err).Write(w)
×
546
                return
×
547
        }
×
548

549
        apicommon.HTTPWriteOK(w)
2✔
550
}
551

552
// reserveManagedProcessSlot reserves one process slot against the integrator's shared
553
// ManagedProcesses quota when org is a managed organization publishing a non-test-sized
554
// election. It returns the integrator address and reserved=true when a slot was taken — the
555
// caller MUST roll it back with AddOrganizationManagedProcesses(integratorAddr, -1) if the
556
// publish/sign later fails. For a standalone org or a test-sized election it is a no-op
557
// (reserved=false, nil error).
558
//
559
// HasTxPermission skips the per-org process-count check for managed orgs precisely because
560
// this integrator-level reservation enforces it instead; every NEW_PROCESS entry point (draft
561
// publish and the remote-signer /transactions path) must call this so the two cannot drift.
562
func (a *API) reserveManagedProcessSlot(org *db.Organization, maxCensusSize uint64) (common.Address, bool, error) {
17✔
563
        if org.ManagedBy == (common.Address{}) || maxCensusSize <= uint64(db.TestMaxCensusSize) {
29✔
564
                return common.Address{}, false, nil
12✔
565
        }
12✔
566
        integrator, err := a.db.Organization(org.ManagedBy)
5✔
567
        if err != nil {
5✔
NEW
568
                // a managed org whose integrator no longer exists is a not-found condition, not a
×
NEW
569
                // server fault — map it like subscriptions.limitsOwner does rather than 500.
×
NEW
570
                if err == db.ErrNotFound {
×
NEW
571
                        return common.Address{}, false, errors.ErrOrganizationNotFound.WithErr(err)
×
NEW
572
                }
×
NEW
573
                return common.Address{}, false, errors.ErrGenericInternalServerError.Withf("could not get integrator organization: %v", err)
×
574
        }
575
        maxProcesses, err := a.subscriptions.ManagedPublishLimits(integrator)
5✔
576
        if err != nil {
5✔
NEW
577
                return common.Address{}, false, err
×
NEW
578
        }
×
579
        if err := a.db.ReserveManagedPublish(integrator.Address, maxProcesses); err != nil {
7✔
580
                if err == db.ErrManagedQuotaReached {
4✔
581
                        return common.Address{}, false, errors.ErrIntegratorQuotaExceeded
2✔
582
                }
2✔
NEW
583
                return common.Address{}, false, errors.ErrGenericInternalServerError.WithErr(err)
×
584
        }
585
        return integrator.Address, true, nil
3✔
586
}
587

588
// publishProcessHandler godoc
589
//
590
//        @Summary                Publish a draft process as an on-chain election
591
//        @Description        Publishes an existing draft process (created via POST /process) as an on-chain
592
//        @Description        election. The backend builds the election metadata and NewProcess transaction,
593
//        @Description        funds and signs it (with the organization signer) synchronously, then submits and
594
//        @Description        confirms it on a background worker; the call returns 202 with a job id. Poll GET
595
//        @Description        /jobs/{jobId} for the resulting on-chain id. Requires Admin role. Idempotent: if
596
//        @Description        the draft is already published its on-chain id is returned with 200 without sending
597
//        @Description        a new transaction. Publishing under a managed organization additionally enforces the
598
//        @Description        integrator's aggregate election (process-count) quota.
599
//        @Description
600
//        @Description        Also callable with a scoped API key (scope: `voting:write`).
601
//        @Tags                        process
602
//        @Accept                        json
603
//        @Produce                json
604
//        @Security                BearerAuth
605
//        @Param                        processId        path                string                                                                true        "Draft process ID"
606
//        @Success                202                        {object}        apicommon.EnqueuedResponse                        "Publish accepted; poll GET /jobs/{jobId}"
607
//        @Success                200                        {object}        apicommon.PublishProcessResponse        "Already published (idempotent): on-chain id and status"
608
//        @Failure                400                        {object}        errors.Error                                                "Invalid input data"
609
//        @Failure                401                        {object}        errors.Error                                                "Unauthorized"
610
//        @Failure                404                        {object}        errors.Error                                                "Process not found"
611
//        @Failure                409                        {object}        errors.Error                                                "A publish is already in progress for this draft"
612
//        @Failure                500                        {object}        errors.Error                                                "Internal server error"
613
//        @Failure                503                        {object}        errors.Error                                                "Transaction queue is full"
614
//        @Router                        /process/{processId}/publish [post]
615
func (a *API) publishProcessHandler(w http.ResponseWriter, r *http.Request) {
8✔
616
        processID := chi.URLParam(r, "processId")
8✔
617
        if processID == "" {
8✔
618
                errors.ErrMalformedURLParam.Withf("missing process ID").Write(w)
×
619
                return
×
620
        }
×
621
        parsedID, err := primitive.ObjectIDFromHex(processID)
8✔
622
        if err != nil {
8✔
623
                errors.ErrMalformedURLParam.Withf("invalid process ID").Write(w)
×
624
                return
×
625
        }
×
626

627
        user, ok := apicommon.UserFromContext(r.Context())
8✔
628
        if !ok {
8✔
629
                errors.ErrUnauthorized.Write(w)
×
630
                return
×
631
        }
×
632

633
        draft, err := a.db.Process(parsedID)
8✔
634
        if err != nil {
8✔
635
                if err == db.ErrNotFound {
×
636
                        errors.ErrProcessNotFound.Write(w)
×
637
                        return
×
638
                }
×
639
                errors.ErrGenericInternalServerError.WithErr(err).Write(w)
×
640
                return
×
641
        }
642

643
        // permission: Admin of the owning organization. Publishing maps to a NEW_PROCESS
644
        // tx, which subscriptions.HasTxPermission requires Admin for, so we enforce the
645
        // same role here rather than letting a Manager pass and be rejected downstream.
646
        if !user.HasRoleFor(draft.OrgAddress, db.AdminRole) {
8✔
647
                errors.ErrUnauthorized.Withf("user is not admin of the organization that owns this process").Write(w)
×
648
                return
×
649
        }
×
650

651
        // idempotent: already published
652
        if !draft.Address.Equals(nil) {
9✔
653
                apicommon.HTTPWriteJSON(w, &apicommon.PublishProcessResponse{Address: draft.Address, Status: draft.Status})
1✔
654
                return
1✔
655
        }
1✔
656

657
        // a draft must carry the high-level election definition to be publishable.
658
        // the on-chain census root is always the CSP public key, so the draft census
659
        // (member list used later by the CSP bundle flow) is not required here.
660
        if draft.ElectionParams == nil {
7✔
661
                errors.ErrMalformedBody.Withf("draft has no election params").Write(w)
×
662
                return
×
663
        }
×
664

665
        // atomically claim the draft for publishing BEFORE any expensive metadata/funding/
666
        // signing work. This single conditional update is the authoritative duplicate-publish
667
        // guard: two concurrent publishes cannot both win the claim, so only one NEW_PROCESS
668
        // tx is ever built for a draft.
669
        claimed, err := a.db.ClaimProcessForPublish(parsedID)
7✔
670
        if err != nil {
7✔
671
                errors.ErrGenericInternalServerError.WithErr(err).Write(w)
×
672
                return
×
673
        }
×
674
        if !claimed {
7✔
675
                // lost the claim: either another request is mid-publish, or it already finished.
×
676
                if cur, e := a.db.Process(parsedID); e == nil && !cur.Address.Equals(nil) {
×
677
                        apicommon.HTTPWriteJSON(w, &apicommon.PublishProcessResponse{Address: cur.Address, Status: cur.Status})
×
678
                        return
×
679
                }
×
680
                errors.ErrPublishInProgress.Write(w)
×
681
                return
×
682
        }
683

684
        // from here the draft is in PUBLISHING. Until a worker owns the job, every failure
685
        // path must release the claim, otherwise the draft is stuck (the release must $unset
686
        // status, since a zero-value string write is dropped by the dynamic update helper).
687
        committed := false
7✔
688
        defer func() {
14✔
689
                if committed {
12✔
690
                        return
5✔
691
                }
5✔
692
                if e := a.db.ClearProcessPublishing(parsedID); e != nil {
2✔
693
                        log.Warnw("could not clear publishing state after failed publish", "error", e)
×
694
                }
×
695
        }()
696

697
        org, err := a.db.Organization(draft.OrgAddress)
7✔
698
        if err != nil {
7✔
699
                if err == db.ErrNotFound {
×
700
                        errors.ErrOrganizationNotFound.Write(w)
×
701
                        return
×
702
                }
×
703
                errors.ErrGenericInternalServerError.WithErr(err).Write(w)
×
704
                return
×
705
        }
706

707
        // if this org is managed by an integrator, atomically reserve the integrator's
708
        // aggregate process quota BEFORE building the on-chain tx, so concurrent publishes
709
        // cannot each pass a stale check and exceed the cap. The reservation is rolled back
710
        // (deferred) unless the publish commits. Test-sized elections are exempt from the
711
        // integrator quota, mirroring the per-org Processes counter exemption below.
712
        nonTestSized := draft.ElectionParams.MaxCensusSize > uint64(db.TestMaxCensusSize)
7✔
713
        integratorAddr, managedReserved, err := a.reserveManagedProcessSlot(org, draft.ElectionParams.MaxCensusSize)
7✔
714
        if err != nil {
8✔
715
                if apiErr, ok := err.(errors.Error); ok {
2✔
716
                        apiErr.Write(w)
1✔
717
                        return
1✔
718
                }
1✔
NEW
719
                errors.ErrGenericInternalServerError.WithErr(err).Write(w)
×
NEW
720
                return
×
721
        }
722
        if managedReserved {
8✔
723
                // roll back the reservation if the publish is not handed to a worker (any
2✔
724
                // synchronous failure below, or a full queue). Once enqueued the worker owns
2✔
725
                // the reservation outcome and clears managedReserved.
2✔
726
                defer func() {
4✔
727
                        if !managedReserved {
3✔
728
                                return
1✔
729
                        }
1✔
730
                        if e := a.db.AddOrganizationManagedProcesses(integratorAddr, -1); e != nil {
1✔
731
                                log.Warnw("could not roll back managed processes counter", "error", e)
×
732
                        }
×
733
                }()
734
        }
735

736
        orgSigner, err := account.OrganizationSigner(a.secret, org.Creator, org.Nonce)
6✔
737
        if err != nil {
6✔
738
                errors.ErrGenericInternalServerError.Withf("could not restore organization signer: %v", err).Write(w)
×
739
                return
×
740
        }
×
741

742
        cspPubKey, err := a.csp.PubKey()
6✔
743
        if err != nil {
6✔
744
                errors.ErrGenericInternalServerError.Withf("could not get csp public key: %v", err).Write(w)
×
745
                return
×
746
        }
×
747

748
        // build + store the election metadata content-addressed; its public URL is the
749
        // on-chain metadata pointer (must be known before the tx, so it cannot contain
750
        // the not-yet-known process id).
751
        metaBytes, err := account.BuildElectionMetadata(draft.ElectionParams)
6✔
752
        if err != nil {
6✔
753
                errors.ErrMalformedBody.Withf("invalid election params: %v", err).Write(w)
×
754
                return
×
755
        }
×
756
        objectName, err := a.objectStorage.PutJSON(metaBytes, user.Email)
6✔
757
        if err != nil {
6✔
758
                errors.ErrInternalStorageError.WithErr(err).Write(w)
×
759
                return
×
760
        }
×
761
        // build the reference via the storage client so the URL format (and trailing-slash
762
        // handling) matches what the read path's local-reference match expects.
763
        metadataURL := a.objectStorage.LocalURL(objectName)
6✔
764
        // persist the reference so the read path resolves it locally without a chain round-trip.
6✔
765
        draft.MetadataURL = metadataURL
6✔
766

6✔
767
        // serialize build->sign->submit per organization so a concurrent publish or status
6✔
768
        // change for the same org cannot read the same account nonce and sign a conflicting
6✔
769
        // tx. The worker releases the lock after submit (held across the async hand-off);
6✔
770
        // every synchronous failure below releases it via the deferred unlock.
6✔
771
        orgLock := a.orgTxLocks.lock(org.Address)
6✔
772
        lockHeld := true
6✔
773
        defer func() {
12✔
774
                if lockHeld {
7✔
775
                        orgLock.Unlock()
1✔
776
                }
1✔
777
        }()
778

779
        // build the NewProcess tx (CSP census)
780
        tx, err := a.account.BuildNewProcessTx(&account.NewProcessParams{
6✔
781
                OrgAddress:  draft.OrgAddress,
6✔
782
                Params:      draft.ElectionParams,
6✔
783
                CensusRoot:  cspPubKey,
6✔
784
                CensusURI:   a.serverURL,
6✔
785
                MetadataURL: metadataURL,
6✔
786
        })
6✔
787
        if err != nil {
6✔
788
                errors.ErrMalformedBody.Withf("could not build election: %v", err).Write(w)
×
789
                return
×
790
        }
×
791

792
        // fund
793
        fundedTx, txType, err := a.account.FundTransaction(tx, orgSigner.Address())
6✔
794
        if err != nil {
6✔
795
                if apiErr, ok := err.(errors.Error); ok {
×
796
                        apiErr.Write(w)
×
797
                        return
×
798
                }
×
799
                errors.ErrVochainRequestFailed.WithErr(err).Write(w)
×
800
                return
×
801
        }
802
        if txType == nil || *txType != models.TxType_NEW_PROCESS {
6✔
803
                errors.ErrInvalidTxFormat.With("unexpected tx type for publish").Write(w)
×
804
                return
×
805
        }
×
806

807
        // quota / permission (same engine as the /transactions path)
808
        if hasPermission, err := a.subscriptions.HasTxPermission(fundedTx, *txType, org, user); !hasPermission || err != nil {
7✔
809
                errors.ErrUnauthorized.Withf("user does not have permission to publish: %v", err).Write(w)
1✔
810
                return
1✔
811
        }
1✔
812

813
        // sign with the organization signer
814
        stx, err := a.account.SignTransaction(fundedTx, orgSigner)
5✔
815
        if err != nil {
5✔
816
                errors.ErrGenericInternalServerError.Withf("could not sign election tx: %v", err).Write(w)
×
817
                return
×
818
        }
×
819

820
        jobID, err := apicommon.NewJobID()
5✔
821
        if err != nil {
5✔
822
                errors.ErrGenericInternalServerError.WithErr(err).Write(w)
×
823
                return
×
824
        }
×
825
        if err := a.db.CreateTxJob(jobID, db.JobTypePublishProcess, org.Address); err != nil {
5✔
826
                errors.ErrGenericInternalServerError.WithErr(err).Write(w)
×
827
                return
×
828
        }
×
829

830
        // submit + confirm on the worker pool. On success the draft becomes READY with its
831
        // on-chain id; on failure the PUBLISHING claim is released and any managed reservation
832
        // is rolled back. The idempotency guard keys off draft.Address, so a retry after
833
        // failure cannot create a duplicate election.
834
        reserved := managedReserved
5✔
835
        if !a.enqueueTx(txTask{jobID: jobID, run: func() (*db.JobResult, error) {
10✔
836
                defer orgLock.Unlock()
5✔
837
                data, err := a.account.SubmitSignedTx(stx)
5✔
838
                if err != nil {
5✔
839
                        if e := a.db.ClearProcessPublishing(parsedID); e != nil {
×
840
                                log.Warnw("could not clear publishing state after failed publish", "error", e)
×
841
                        }
×
842
                        if reserved {
×
843
                                if e := a.db.AddOrganizationManagedProcesses(integratorAddr, -1); e != nil {
×
844
                                        log.Warnw("could not roll back managed processes counter", "error", e)
×
845
                                }
×
846
                        }
847
                        return nil, err
×
848
                }
849
                draft.Address = internal.HexBytes(data)
5✔
850
                draft.Status = "READY"
5✔
851
                draft.PublishedAt = time.Now()
5✔
852
                if _, err := a.db.SetProcess(draft); err != nil {
5✔
853
                        return nil, err
×
854
                }
×
855
                // best-effort per-org Processes counter; only count non-test-sized elections.
856
                if nonTestSized {
9✔
857
                        if err := a.db.IncrementOrganizationProcessesCounter(org.Address); err != nil {
4✔
858
                                log.Warnw("could not update organization process counter", "error", err)
×
859
                        }
×
860
                }
861
                return &db.JobResult{Address: draft.Address, Status: "READY"}, nil
5✔
862
        }}) {
×
863
                // full queue: mark the job failed so it is not orphaned pending; the deferred
×
864
                // unlock, publishing-claim release and reservation rollback all fire on return.
×
865
                if e := a.db.SetJobStatus(jobID, db.JobStatusFailed, nil, "tx queue full"); e != nil {
×
866
                        log.Warnw("could not mark job failed after full queue", "error", e)
×
867
                }
×
868
                errors.ErrTxQueueFull.Write(w)
×
869
                return
×
870
        }
871
        // handed to a worker: it now owns the publish claim, reservation and org lock.
872
        committed = true
5✔
873
        managedReserved = false
5✔
874
        lockHeld = false
5✔
875

5✔
876
        apicommon.HTTPWriteJSONStatus(w, http.StatusAccepted, &apicommon.EnqueuedResponse{JobID: jobID})
5✔
877
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc