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

supabase / gotrue / 8135412125

04 Mar 2024 03:34AM UTC coverage: 65.142% (+0.1%) from 65.009%
8135412125

push

github

web-flow
fix: refactor request params to use generics (#1464)

## What kind of change does this PR introduce?
* Introduce a new method `retrieveRequestParams` which makes use of
generics to parse a request
* This will help to simplify parsing a request from:
```go

params := RequestParams{}
body, err := getBodyBytes(r)
if err != nil {
  return nil, badRequestError("Could not read body").WithInternalError(err)
}

if err := json.Unmarshal(body, &params); err != nil {
  return nil, badRequestError("Could not decode request params: %v", err)
}
```
to 
```go
params := &Request{}
err := retrieveRequestParams(req, params)
```

## TODO
- [x] Add type constraint instead of using `any`

48 of 69 new or added lines in 19 files covered. (69.57%)

19 existing lines in 14 files now uncovered.

7806 of 11983 relevant lines covered (65.14%)

59.29 hits per line

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

62.84
/internal/api/admin.go
1
package api
2

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

10
        "github.com/fatih/structs"
11
        "github.com/go-chi/chi"
12
        "github.com/gofrs/uuid"
13
        "github.com/sethvargo/go-password/password"
14
        "github.com/supabase/auth/internal/api/provider"
15
        "github.com/supabase/auth/internal/models"
16
        "github.com/supabase/auth/internal/observability"
17
        "github.com/supabase/auth/internal/storage"
18
)
19

20
type AdminUserParams struct {
21
        Aud          string                 `json:"aud"`
22
        Role         string                 `json:"role"`
23
        Email        string                 `json:"email"`
24
        Phone        string                 `json:"phone"`
25
        Password     *string                `json:"password"`
26
        EmailConfirm bool                   `json:"email_confirm"`
27
        PhoneConfirm bool                   `json:"phone_confirm"`
28
        UserMetaData map[string]interface{} `json:"user_metadata"`
29
        AppMetaData  map[string]interface{} `json:"app_metadata"`
30
        BanDuration  string                 `json:"ban_duration"`
31
}
32

33
type adminUserDeleteParams struct {
34
        ShouldSoftDelete bool `json:"should_soft_delete"`
35
}
36

37
type adminUserUpdateFactorParams struct {
38
        FriendlyName string `json:"friendly_name"`
39
        FactorType   string `json:"factor_type"`
40
}
41

42
type AdminListUsersResponse struct {
43
        Users []*models.User `json:"users"`
44
        Aud   string         `json:"aud"`
45
}
46

47
func (a *API) loadUser(w http.ResponseWriter, r *http.Request) (context.Context, error) {
16✔
48
        ctx := r.Context()
16✔
49
        db := a.db.WithContext(ctx)
16✔
50

16✔
51
        userID, err := uuid.FromString(chi.URLParam(r, "user_id"))
16✔
52
        if err != nil {
16✔
53
                return nil, badRequestError("user_id must be an UUID")
×
54
        }
×
55

56
        observability.LogEntrySetField(r, "user_id", userID)
16✔
57

16✔
58
        u, err := models.FindUserByID(db, userID)
16✔
59
        if err != nil {
16✔
60
                if models.IsNotFoundError(err) {
×
61
                        return nil, notFoundError("User not found")
×
62
                }
×
63
                return nil, internalServerError("Database error loading user").WithInternalError(err)
×
64
        }
65

66
        return withUser(ctx, u), nil
16✔
67
}
68

69
func (a *API) loadFactor(w http.ResponseWriter, r *http.Request) (context.Context, error) {
25✔
70
        factorID, err := uuid.FromString(chi.URLParam(r, "factor_id"))
25✔
71
        if err != nil {
25✔
72
                return nil, badRequestError("factor_id must be an UUID")
×
73
        }
×
74

75
        observability.LogEntrySetField(r, "factor_id", factorID)
25✔
76

25✔
77
        f, err := models.FindFactorByFactorID(a.db, factorID)
25✔
78
        if err != nil {
25✔
79
                if models.IsNotFoundError(err) {
×
80
                        return nil, notFoundError("Factor not found")
×
81
                }
×
82
                return nil, internalServerError("Database error loading factor").WithInternalError(err)
×
83
        }
84
        return withFactor(r.Context(), f), nil
25✔
85
}
86

87
func (a *API) getAdminParams(r *http.Request) (*AdminUserParams, error) {
11✔
88
        params := &AdminUserParams{}
11✔
89
        if err := retrieveRequestParams(r, params); err != nil {
11✔
NEW
90
                return nil, err
×
UNCOV
91
        }
×
92

93
        return params, nil
11✔
94
}
95

96
// adminUsers responds with a list of all users in a given audience
97
func (a *API) adminUsers(w http.ResponseWriter, r *http.Request) error {
6✔
98
        ctx := r.Context()
6✔
99
        db := a.db.WithContext(ctx)
6✔
100
        aud := a.requestAud(ctx, r)
6✔
101

6✔
102
        pageParams, err := paginate(r)
6✔
103
        if err != nil {
6✔
104
                return badRequestError("Bad Pagination Parameters: %v", err)
×
105
        }
×
106

107
        sortParams, err := sort(r, map[string]bool{models.CreatedAt: true}, []models.SortField{{Name: models.CreatedAt, Dir: models.Descending}})
6✔
108
        if err != nil {
6✔
109
                return badRequestError("Bad Sort Parameters: %v", err)
×
110
        }
×
111

112
        filter := r.URL.Query().Get("filter")
6✔
113

6✔
114
        users, err := models.FindUsersInAudience(db, aud, pageParams, sortParams, filter)
6✔
115
        if err != nil {
6✔
116
                return internalServerError("Database error finding users").WithInternalError(err)
×
117
        }
×
118
        addPaginationHeaders(w, r, pageParams)
6✔
119

6✔
120
        return sendJSON(w, http.StatusOK, AdminListUsersResponse{
6✔
121
                Users: users,
6✔
122
                Aud:   aud,
6✔
123
        })
6✔
124
}
125

126
// adminUserGet returns information about a single user
127
func (a *API) adminUserGet(w http.ResponseWriter, r *http.Request) error {
1✔
128
        user := getUser(r.Context())
1✔
129

1✔
130
        return sendJSON(w, http.StatusOK, user)
1✔
131
}
1✔
132

133
// adminUserUpdate updates a single user object
134
func (a *API) adminUserUpdate(w http.ResponseWriter, r *http.Request) error {
3✔
135
        ctx := r.Context()
3✔
136
        db := a.db.WithContext(ctx)
3✔
137
        user := getUser(ctx)
3✔
138
        adminUser := getAdminUser(ctx)
3✔
139
        params, err := a.getAdminParams(r)
3✔
140
        if err != nil {
3✔
141
                return err
×
142
        }
×
143

144
        if params.Email != "" {
4✔
145
                params.Email, err = validateEmail(params.Email)
1✔
146
                if err != nil {
1✔
147
                        return err
×
148
                }
×
149
        }
150

151
        if params.Phone != "" {
4✔
152
                params.Phone, err = validatePhone(params.Phone)
1✔
153
                if err != nil {
1✔
154
                        return err
×
155
                }
×
156
        }
157

158
        if params.BanDuration != "" {
5✔
159
                duration := time.Duration(0)
2✔
160
                if params.BanDuration != "none" {
4✔
161
                        duration, err = time.ParseDuration(params.BanDuration)
2✔
162
                        if err != nil {
3✔
163
                                return badRequestError("invalid format for ban duration: %v", err)
1✔
164
                        }
1✔
165
                }
166
                if terr := user.Ban(a.db, duration); terr != nil {
1✔
167
                        return terr
×
168
                }
×
169
        }
170

171
        if params.Password != nil {
3✔
172
                password := *params.Password
1✔
173

1✔
174
                if err := a.checkPasswordStrength(ctx, password); err != nil {
2✔
175
                        return err
1✔
176
                }
1✔
177

178
                if err := user.SetPassword(ctx, password); err != nil {
×
179
                        return err
×
180
                }
×
181
        }
182

183
        err = db.Transaction(func(tx *storage.Connection) error {
2✔
184
                if params.Role != "" {
2✔
185
                        if terr := user.SetRole(tx, params.Role); terr != nil {
1✔
186
                                return terr
×
187
                        }
×
188
                }
189

190
                if params.EmailConfirm {
1✔
191
                        if terr := user.Confirm(tx); terr != nil {
×
192
                                return terr
×
193
                        }
×
194
                }
195

196
                if params.PhoneConfirm {
1✔
197
                        if terr := user.ConfirmPhone(tx); terr != nil {
×
198
                                return terr
×
199
                        }
×
200
                }
201

202
                if params.Password != nil {
1✔
203
                        if terr := user.UpdatePassword(tx, nil); terr != nil {
×
204
                                return terr
×
205
                        }
×
206
                }
207

208
                var identities []models.Identity
1✔
209
                if params.Email != "" {
2✔
210
                        if identity, terr := models.FindIdentityByIdAndProvider(tx, user.ID.String(), "email"); terr != nil && !models.IsNotFoundError(terr) {
1✔
211
                                return terr
×
212
                        } else if identity == nil {
2✔
213
                                // if the user doesn't have an existing email
1✔
214
                                // then updating the user's email should create a new email identity
1✔
215
                                i, terr := a.createNewIdentity(tx, user, "email", structs.Map(provider.Claims{
1✔
216
                                        Subject: user.ID.String(),
1✔
217
                                        Email:   params.Email,
1✔
218
                                }))
1✔
219
                                if terr != nil {
1✔
220
                                        return terr
×
221
                                }
×
222
                                identities = append(identities, *i)
1✔
223
                        } else {
×
224
                                // update the existing email identity
×
225
                                if terr := identity.UpdateIdentityData(tx, map[string]interface{}{
×
226
                                        "email": params.Email,
×
227
                                }); terr != nil {
×
228
                                        return terr
×
229
                                }
×
230
                        }
231
                        if terr := user.SetEmail(tx, params.Email); terr != nil {
1✔
232
                                return terr
×
233
                        }
×
234
                }
235

236
                if params.Phone != "" {
2✔
237
                        if identity, terr := models.FindIdentityByIdAndProvider(tx, user.ID.String(), "phone"); terr != nil && !models.IsNotFoundError(terr) {
1✔
238
                                return terr
×
239
                        } else if identity == nil {
2✔
240
                                // if the user doesn't have an existing phone
1✔
241
                                // then updating the user's phone should create a new phone identity
1✔
242
                                identity, terr := a.createNewIdentity(tx, user, "phone", structs.Map(provider.Claims{
1✔
243
                                        Subject: user.ID.String(),
1✔
244
                                        Phone:   params.Phone,
1✔
245
                                }))
1✔
246
                                if terr != nil {
1✔
247
                                        return terr
×
248
                                }
×
249
                                identities = append(identities, *identity)
1✔
250
                        } else {
×
251
                                // update the existing phone identity
×
252
                                if terr := identity.UpdateIdentityData(tx, map[string]interface{}{
×
253
                                        "phone": params.Phone,
×
254
                                }); terr != nil {
×
255
                                        return terr
×
256
                                }
×
257
                        }
258
                        if terr := user.SetPhone(tx, params.Phone); terr != nil {
1✔
259
                                return terr
×
260
                        }
×
261
                }
262
                user.Identities = append(user.Identities, identities...)
1✔
263

1✔
264
                if params.AppMetaData != nil {
2✔
265
                        if terr := user.UpdateAppMetaData(tx, params.AppMetaData); terr != nil {
1✔
266
                                return terr
×
267
                        }
×
268
                }
269

270
                if params.UserMetaData != nil {
2✔
271
                        if terr := user.UpdateUserMetaData(tx, params.UserMetaData); terr != nil {
1✔
272
                                return terr
×
273
                        }
×
274
                }
275

276
                if terr := models.NewAuditLogEntry(r, tx, adminUser, models.UserModifiedAction, "", map[string]interface{}{
1✔
277
                        "user_id":    user.ID,
1✔
278
                        "user_email": user.Email,
1✔
279
                        "user_phone": user.Phone,
1✔
280
                }); terr != nil {
1✔
281
                        return terr
×
282
                }
×
283
                return nil
1✔
284
        })
285

286
        if err != nil {
1✔
287
                return internalServerError("Error updating user").WithInternalError(err)
×
288
        }
×
289

290
        return sendJSON(w, http.StatusOK, user)
1✔
291
}
292

293
// adminUserCreate creates a new user based on the provided data
294
func (a *API) adminUserCreate(w http.ResponseWriter, r *http.Request) error {
8✔
295
        ctx := r.Context()
8✔
296
        db := a.db.WithContext(ctx)
8✔
297
        config := a.config
8✔
298

8✔
299
        adminUser := getAdminUser(ctx)
8✔
300
        params, err := a.getAdminParams(r)
8✔
301
        if err != nil {
8✔
302
                return err
×
303
        }
×
304

305
        aud := a.requestAud(ctx, r)
8✔
306
        if params.Aud != "" {
8✔
307
                aud = params.Aud
×
308
        }
×
309

310
        if params.Email == "" && params.Phone == "" {
8✔
311
                return unprocessableEntityError("Cannot create a user without either an email or phone")
×
312
        }
×
313

314
        var providers []string
8✔
315
        if params.Email != "" {
14✔
316
                params.Email, err = validateEmail(params.Email)
6✔
317
                if err != nil {
6✔
318
                        return err
×
319
                }
×
320
                if user, err := models.IsDuplicatedEmail(db, params.Email, aud, nil); err != nil {
6✔
321
                        return internalServerError("Database error checking email").WithInternalError(err)
×
322
                } else if user != nil {
6✔
323
                        return unprocessableEntityError(DuplicateEmailMsg)
×
324
                }
×
325
                providers = append(providers, "email")
6✔
326
        }
327

328
        if params.Phone != "" {
11✔
329
                params.Phone, err = validatePhone(params.Phone)
3✔
330
                if err != nil {
3✔
331
                        return err
×
332
                }
×
333
                if exists, err := models.IsDuplicatedPhone(db, params.Phone, aud); err != nil {
3✔
334
                        return internalServerError("Database error checking phone").WithInternalError(err)
×
335
                } else if exists {
3✔
336
                        return unprocessableEntityError("Phone number already registered by another user")
×
337
                }
×
338
                providers = append(providers, "phone")
3✔
339
        }
340

341
        if params.Password == nil || *params.Password == "" {
10✔
342
                password, err := password.Generate(64, 10, 0, false, true)
2✔
343
                if err != nil {
2✔
344
                        return internalServerError("Error generating password").WithInternalError(err)
×
345
                }
×
346
                params.Password = &password
2✔
347
        }
348

349
        user, err := models.NewUser(params.Phone, params.Email, *params.Password, aud, params.UserMetaData)
8✔
350
        if err != nil {
8✔
351
                return internalServerError("Error creating user").WithInternalError(err)
×
352
        }
×
353

354
        user.AppMetaData = map[string]interface{}{
8✔
355
                // TODO: Deprecate "provider" field
8✔
356
                // default to the first provider in the providers slice
8✔
357
                "provider":  providers[0],
8✔
358
                "providers": providers,
8✔
359
        }
8✔
360

8✔
361
        err = db.Transaction(func(tx *storage.Connection) error {
16✔
362
                if terr := tx.Create(user); terr != nil {
8✔
363
                        return terr
×
364
                }
×
365

366
                var identities []models.Identity
8✔
367
                if user.GetEmail() != "" {
14✔
368
                        identity, terr := a.createNewIdentity(tx, user, "email", structs.Map(provider.Claims{
6✔
369
                                Subject: user.ID.String(),
6✔
370
                                Email:   user.GetEmail(),
6✔
371
                        }))
6✔
372

6✔
373
                        if terr != nil {
6✔
374
                                return terr
×
375
                        }
×
376
                        identities = append(identities, *identity)
6✔
377
                }
378

379
                if user.GetPhone() != "" {
11✔
380
                        identity, terr := a.createNewIdentity(tx, user, "phone", structs.Map(provider.Claims{
3✔
381
                                Subject: user.ID.String(),
3✔
382
                                Phone:   user.GetPhone(),
3✔
383
                        }))
3✔
384

3✔
385
                        if terr != nil {
3✔
386
                                return terr
×
387
                        }
×
388
                        identities = append(identities, *identity)
3✔
389
                }
390

391
                user.Identities = identities
8✔
392

8✔
393
                if terr := models.NewAuditLogEntry(r, tx, adminUser, models.UserSignedUpAction, "", map[string]interface{}{
8✔
394
                        "user_id":    user.ID,
8✔
395
                        "user_email": user.Email,
8✔
396
                        "user_phone": user.Phone,
8✔
397
                }); terr != nil {
8✔
398
                        return terr
×
399
                }
×
400

401
                role := config.JWT.DefaultGroupName
8✔
402
                if params.Role != "" {
8✔
403
                        role = params.Role
×
404
                }
×
405
                if terr := user.SetRole(tx, role); terr != nil {
8✔
406
                        return terr
×
407
                }
×
408

409
                if params.AppMetaData != nil {
8✔
410
                        if terr := user.UpdateAppMetaData(tx, params.AppMetaData); terr != nil {
×
411
                                return terr
×
412
                        }
×
413
                }
414

415
                if params.EmailConfirm {
8✔
416
                        if terr := user.Confirm(tx); terr != nil {
×
417
                                return terr
×
418
                        }
×
419
                }
420

421
                if params.PhoneConfirm {
8✔
422
                        if terr := user.ConfirmPhone(tx); terr != nil {
×
423
                                return terr
×
424
                        }
×
425
                }
426

427
                if params.BanDuration != "" {
9✔
428
                        duration := time.Duration(0)
1✔
429
                        if params.BanDuration != "none" {
2✔
430
                                duration, err = time.ParseDuration(params.BanDuration)
1✔
431
                                if err != nil {
1✔
432
                                        return badRequestError("invalid format for ban duration: %v", err)
×
433
                                }
×
434
                        }
435
                        if terr := user.Ban(a.db, duration); terr != nil {
1✔
436
                                return terr
×
437
                        }
×
438
                }
439

440
                return nil
8✔
441
        })
442

443
        if err != nil {
8✔
444
                if strings.Contains("invalid format for ban duration", err.Error()) {
×
445
                        return err
×
446
                }
×
447
                return internalServerError("Database error creating new user").WithInternalError(err)
×
448
        }
449

450
        return sendJSON(w, http.StatusOK, user)
8✔
451
}
452

453
// adminUserDelete deletes a user
454
func (a *API) adminUserDelete(w http.ResponseWriter, r *http.Request) error {
7✔
455
        ctx := r.Context()
7✔
456
        user := getUser(ctx)
7✔
457
        adminUser := getAdminUser(ctx)
7✔
458

7✔
459
        var err error
7✔
460
        params := &adminUserDeleteParams{}
7✔
461
        body, err := getBodyBytes(r)
7✔
462
        if err != nil {
7✔
463
                return badRequestError("Could not read body").WithInternalError(err)
×
464
        }
×
465
        if len(body) > 0 {
12✔
466
                if err := json.Unmarshal(body, params); err != nil {
5✔
467
                        return badRequestError("Could not read params: %v", err)
×
468
                }
×
469
        } else {
2✔
470
                params.ShouldSoftDelete = false
2✔
471
        }
2✔
472

473
        err = a.db.Transaction(func(tx *storage.Connection) error {
14✔
474
                if terr := models.NewAuditLogEntry(r, tx, adminUser, models.UserDeletedAction, "", map[string]interface{}{
7✔
475
                        "user_id":    user.ID,
7✔
476
                        "user_email": user.Email,
7✔
477
                        "user_phone": user.Phone,
7✔
478
                }); terr != nil {
7✔
479
                        return internalServerError("Error recording audit log entry").WithInternalError(terr)
×
480
                }
×
481

482
                if params.ShouldSoftDelete {
10✔
483
                        if user.DeletedAt != nil {
3✔
484
                                // user has been soft deleted already
×
485
                                return nil
×
486
                        }
×
487
                        if terr := user.SoftDeleteUser(tx); terr != nil {
3✔
488
                                return internalServerError("Error soft deleting user").WithInternalError(terr)
×
489
                        }
×
490

491
                        if terr := user.SoftDeleteUserIdentities(tx); terr != nil {
3✔
492
                                return internalServerError("Error soft deleting user identities").WithInternalError(terr)
×
493
                        }
×
494

495
                        // hard delete all associated factors
496
                        if terr := models.DeleteFactorsByUserId(tx, user.ID); terr != nil {
3✔
497
                                return internalServerError("Error deleting user's factors").WithInternalError(terr)
×
498
                        }
×
499
                        // hard delete all associated sessions
500
                        if terr := models.Logout(tx, user.ID); terr != nil {
3✔
501
                                return internalServerError("Error deleting user's sessions").WithInternalError(terr)
×
502
                        }
×
503
                        // for backward compatibility: hard delete all associated refresh tokens
504
                        if terr := models.LogoutAllRefreshTokens(tx, user.ID); terr != nil {
3✔
505
                                return internalServerError("Error deleting user's refresh tokens").WithInternalError(terr)
×
506
                        }
×
507
                } else {
4✔
508
                        if terr := tx.Destroy(user); terr != nil {
4✔
509
                                return internalServerError("Database error deleting user").WithInternalError(terr)
×
510
                        }
×
511
                }
512

513
                return nil
7✔
514
        })
515
        if err != nil {
7✔
516
                return err
×
517
        }
×
518

519
        return sendJSON(w, http.StatusOK, map[string]interface{}{})
7✔
520
}
521

522
func (a *API) adminUserDeleteFactor(w http.ResponseWriter, r *http.Request) error {
1✔
523
        ctx := r.Context()
1✔
524
        user := getUser(ctx)
1✔
525
        factor := getFactor(ctx)
1✔
526

1✔
527
        err := a.db.Transaction(func(tx *storage.Connection) error {
2✔
528
                if terr := models.NewAuditLogEntry(r, tx, user, models.DeleteFactorAction, r.RemoteAddr, map[string]interface{}{
1✔
529
                        "user_id":   user.ID,
1✔
530
                        "factor_id": factor.ID,
1✔
531
                }); terr != nil {
1✔
532
                        return terr
×
533
                }
×
534
                if terr := tx.Destroy(factor); terr != nil {
1✔
535
                        return internalServerError("Database error deleting factor").WithInternalError(terr)
×
536
                }
×
537
                return nil
1✔
538
        })
539
        if err != nil {
1✔
540
                return err
×
541
        }
×
542
        return sendJSON(w, http.StatusOK, factor)
1✔
543
}
544

545
func (a *API) adminUserGetFactors(w http.ResponseWriter, r *http.Request) error {
1✔
546
        ctx := r.Context()
1✔
547
        user := getUser(ctx)
1✔
548
        factors, terr := models.FindFactorsByUser(a.db, user)
1✔
549
        if terr != nil {
1✔
550
                return terr
×
551
        }
×
552
        return sendJSON(w, http.StatusOK, factors)
1✔
553
}
554

555
// adminUserUpdate updates a single factor object
556
func (a *API) adminUserUpdateFactor(w http.ResponseWriter, r *http.Request) error {
3✔
557
        ctx := r.Context()
3✔
558
        factor := getFactor(ctx)
3✔
559
        user := getUser(ctx)
3✔
560
        adminUser := getAdminUser(ctx)
3✔
561
        params := &adminUserUpdateFactorParams{}
3✔
562
        if err := retrieveRequestParams(r, params); err != nil {
3✔
NEW
563
                return err
×
UNCOV
564
        }
×
565

566
        err := a.db.Transaction(func(tx *storage.Connection) error {
6✔
567
                if params.FriendlyName != "" {
5✔
568
                        if terr := factor.UpdateFriendlyName(tx, params.FriendlyName); terr != nil {
2✔
569
                                return terr
×
570
                        }
×
571
                }
572
                if params.FactorType != "" {
5✔
573
                        if params.FactorType != models.TOTP {
3✔
574
                                return badRequestError("Factor Type not valid")
1✔
575
                        }
1✔
576
                        if terr := factor.UpdateFactorType(tx, params.FactorType); terr != nil {
1✔
577
                                return terr
×
578
                        }
×
579
                }
580

581
                if terr := models.NewAuditLogEntry(r, tx, adminUser, models.UpdateFactorAction, "", map[string]interface{}{
2✔
582
                        "user_id":     user.ID,
2✔
583
                        "factor_id":   factor.ID,
2✔
584
                        "factor_type": factor.FactorType,
2✔
585
                }); terr != nil {
2✔
586
                        return terr
×
587
                }
×
588
                return nil
2✔
589
        })
590
        if err != nil {
4✔
591
                return err
1✔
592
        }
1✔
593

594
        return sendJSON(w, http.StatusOK, factor)
2✔
595
}
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

© 2025 Coveralls, Inc