@johnnyboy11234/esg-idea-contest 0.3.0 → 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/backend/api/openapi.yaml +119 -0
- package/backend/cmd/server/main.go +36 -2
- package/backend/db/migration/V005__idea.sql +49 -0
- package/backend/db/migration/V006__login_lock.sql +15 -0
- package/backend/db/migration/V007__login_attempt.sql +15 -0
- package/backend/db/migration/V008__pending_file_deletion.sql +27 -0
- package/backend/db/migration/V009__login_throttle_audit.sql +13 -0
- package/backend/go.mod +17 -3
- package/backend/go.sum +48 -7
- package/backend/internal/account/account_test.go +6 -6
- package/backend/internal/account/integration_test.go +45 -9
- package/backend/internal/account/internal/passwords/NOTICE +36 -0
- package/backend/internal/account/internal/passwords/common-passwords.txt +10001 -0
- package/backend/internal/account/internal/passwords/passwords.go +37 -0
- package/backend/internal/account/internal/passwords/passwords_test.go +35 -0
- package/backend/internal/account/keyboard.go +75 -0
- package/backend/internal/account/keyboard_test.go +43 -0
- package/backend/internal/account/logging_integration_test.go +1 -1
- package/backend/internal/account/password.go +131 -0
- package/backend/internal/account/sequence.go +54 -0
- package/backend/internal/account/sequence_test.go +38 -0
- package/backend/internal/account/validate.go +4 -21
- package/backend/internal/api/api.gen.go +262 -0
- package/backend/internal/auth/internal/store/login_attempt.go +53 -0
- package/backend/internal/auth/internal/store/login_lock.go +77 -0
- package/backend/internal/auth/internal/store/login_throttle_audit.go +42 -0
- package/backend/internal/auth/internal/store/session.go +15 -7
- package/backend/internal/auth/login.go +16 -4
- package/backend/internal/auth/login_integration_test.go +14 -1
- package/backend/internal/auth/login_ip_integration_test.go +153 -0
- package/backend/internal/auth/login_lock_integration_test.go +205 -0
- package/backend/internal/auth/login_test.go +15 -0
- package/backend/internal/auth/login_throttle_audit_integration_test.go +88 -0
- package/backend/internal/auth/purge.go +93 -0
- package/backend/internal/auth/purge_integration_test.go +220 -0
- package/backend/internal/auth/purge_test.go +51 -0
- package/backend/internal/auth/session.go +0 -6
- package/backend/internal/auth/session_integration_test.go +3 -3
- package/backend/internal/auth/throttle.go +122 -0
- package/backend/internal/deadline/gate.go +80 -0
- package/backend/internal/deadline/gate_integration_test.go +67 -0
- package/backend/internal/deadline/gate_test.go +61 -0
- package/backend/internal/idea/create.go +356 -0
- package/backend/internal/idea/create_integration_test.go +680 -0
- package/backend/internal/idea/download.go +193 -0
- package/backend/internal/idea/download_integration_test.go +311 -0
- package/backend/internal/idea/download_test.go +25 -0
- package/backend/internal/idea/handler.go +151 -0
- package/backend/internal/idea/handler_integration_test.go +107 -0
- package/backend/internal/idea/integration_test.go +149 -0
- package/backend/internal/idea/internal/store/list.go +146 -0
- package/backend/internal/idea/internal/store/list_test.go +33 -0
- package/backend/internal/idea/internal/store/store.go +166 -0
- package/backend/internal/idea/list.go +166 -0
- package/backend/internal/idea/list_integration_test.go +252 -0
- package/backend/internal/idea/list_test.go +96 -0
- package/backend/internal/idea/service.go +54 -0
- package/backend/internal/server/api.go +22 -0
- package/backend/internal/server/binding.go +4 -0
- package/backend/internal/server/public.go +1 -0
- package/backend/internal/server/server.go +20 -1
- package/backend/internal/server/slides.go +21 -0
- package/backend/internal/server/slides_test.go +82 -0
- package/backend/internal/shared/config/config.go +44 -1
- package/backend/internal/shared/config/config_test.go +21 -13
- package/backend/internal/shared/db/intx_integration_test.go +179 -0
- package/backend/internal/shared/dbtest/dbtest.go +21 -3
- package/backend/internal/shared/dbtest/faults.go +162 -0
- package/backend/internal/shared/job/job.go +54 -0
- package/backend/internal/shared/job/job_test.go +105 -0
- package/backend/internal/shared/web/multipart.go +175 -0
- package/backend/internal/shared/web/multipart_test.go +169 -0
- package/backend/internal/shared/web/problem.go +1 -0
- package/backend/internal/shared/web/request.go +42 -1
- package/backend/internal/shared/web/transport.go +4 -3
- package/backend/internal/shared/web/web_test.go +36 -0
- package/backend/internal/storage/expectedfilename.go +43 -0
- package/backend/internal/storage/expectedfilename_test.go +16 -0
- package/backend/internal/storage/internal/store/pending.go +41 -0
- package/backend/internal/storage/minio.go +139 -0
- package/backend/internal/storage/minio_integration_test.go +87 -0
- package/backend/internal/storage/minio_test.go +84 -0
- package/backend/internal/storage/pending.go +144 -0
- package/backend/internal/storage/pending_integration_test.go +324 -0
- package/backend/internal/storage/pending_test.go +23 -0
- package/backend/internal/storage/validator.go +100 -0
- package/backend/internal/storage/validator_test.go +95 -0
- package/backend/test/arch/arch_test.go +5 -1
- package/backend/test/contract/expected_filename_test.go +60 -0
- package/backend/test/contract/password_test.go +46 -0
- package/backend/test/matrix/load_test.go +12 -0
- package/backend/test/matrix/matrix_integration_test.go +92 -5
- package/backend/test/migration/pending_file_deletion_test.go +46 -0
- package/docs/API/api-endpoints.md +1 -1
- package/docs/API/error-codes.md +1 -1
- package/docs/Architecture/Architecture.md +1 -1
- package/docs/Architecture/Backend.md +2 -1
- package/frontend/Dockerfile +2 -0
- package/frontend/package-lock.json +1 -0
- package/frontend/package.json +1 -0
- package/frontend/src/app/App.tsx +11 -2
- package/frontend/src/app/pages/ideaCreate.test.tsx +308 -0
- package/frontend/src/app/pages/ideas.tsx +23 -9
- package/frontend/src/app/pages/login.test.tsx +38 -1
- package/frontend/src/app/pages/register.test.tsx +32 -0
- package/frontend/src/features/auth/IdleWarning.test.tsx +82 -0
- package/frontend/src/features/auth/IdleWarning.tsx +66 -0
- package/frontend/src/features/auth/LoginForm.tsx +21 -3
- package/frontend/src/features/auth/PasswordChecklist.test.tsx +73 -0
- package/frontend/src/features/auth/PasswordChecklist.tsx +96 -0
- package/frontend/src/features/auth/RegisterForm.tsx +11 -2
- package/frontend/src/features/auth/common-passwords.ts +31 -0
- package/frontend/src/features/auth/password-policy.test.ts +64 -0
- package/frontend/src/features/auth/password-policy.ts +168 -0
- package/frontend/src/features/auth/useCountdown.test.tsx +33 -0
- package/frontend/src/features/auth/useCountdown.ts +28 -0
- package/frontend/src/features/idea/CreateIdeaForm.tsx +279 -0
- package/frontend/src/features/idea/create.ts +123 -0
- package/frontend/src/shared/api/activity.ts +20 -0
- package/frontend/src/shared/api/http.ts +2 -0
- package/frontend/src/shared/api/index.ts +1 -0
- package/frontend/src/shared/api/types.generated.ts +319 -1
- package/frontend/src/shared/i18n/locales/en.ts +46 -4
- package/frontend/src/shared/i18n/locales/zh-TW.ts +46 -4
- package/frontend/src/shared/slides/index.ts +74 -0
- package/frontend/src/shared/slides/slides.test.ts +51 -0
- package/frontend/src/shared/ui/copy-button.tsx +52 -0
- package/frontend/src/shared/ui/native-select.tsx +27 -0
- package/frontend/src/shared/ui/textarea.tsx +19 -0
- package/frontend/src/test-setup.ts +37 -2
- package/frontend/vite.config.ts +3 -1
- package/package.json +1 -1
- package/testdata/expected-filename.json +93 -0
- package/testdata/password.json +582 -0
- package/testdata/permission-matrix.json +71 -0
- package/testdata/schema/expected-filename.schema.json +32 -0
- package/testdata/schema/password.schema.json +59 -0
- package/testdata/schema/permission-matrix.schema.json +22 -1
|
@@ -71,6 +71,27 @@ func (e LoginResponseRole) Valid() bool {
|
|
|
71
71
|
}
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
+
// CreateIdeaForm defines model for CreateIdeaForm.
|
|
75
|
+
type CreateIdeaForm struct {
|
|
76
|
+
// Category COOL_IDEA or OUTSTANDING_IMPACT
|
|
77
|
+
Category string `json:"category"`
|
|
78
|
+
|
|
79
|
+
// Direction One of the five ESG directions (PRD 6.6)
|
|
80
|
+
Direction string `json:"direction"`
|
|
81
|
+
|
|
82
|
+
// Name 1-75 code points after clean-up; no / \ : * ? " < > |
|
|
83
|
+
Name string `json:"name"`
|
|
84
|
+
|
|
85
|
+
// Org AAID, BSID, ICSD, TSID, PLED or DEID
|
|
86
|
+
Org string `json:"org"`
|
|
87
|
+
|
|
88
|
+
// Slides One PDF or PPTX, at most 5 MiB, named {I|O}_{org}_{leaderName}_{leaderEmpNo}_{name}.{pdf|pptx}
|
|
89
|
+
Slides openapi_types.File `json:"slides"`
|
|
90
|
+
|
|
91
|
+
// Summary 1-300 code points after clean-up
|
|
92
|
+
Summary string `json:"summary"`
|
|
93
|
+
}
|
|
94
|
+
|
|
74
95
|
// CsrfTokenResponse defines model for CsrfTokenResponse.
|
|
75
96
|
type CsrfTokenResponse struct {
|
|
76
97
|
Token string `json:"token"`
|
|
@@ -93,6 +114,9 @@ type FieldError struct {
|
|
|
93
114
|
Code string `json:"code"`
|
|
94
115
|
Expected *string `json:"expected,omitempty"`
|
|
95
116
|
Field string `json:"field"`
|
|
117
|
+
|
|
118
|
+
// Rule PASSWORD_POLICY only, the violated rule id (PRD 6.4)
|
|
119
|
+
Rule *string `json:"rule,omitempty"`
|
|
96
120
|
}
|
|
97
121
|
|
|
98
122
|
// IdeaDetail defines model for IdeaDetail.
|
|
@@ -245,6 +269,33 @@ type RegisterParams struct {
|
|
|
245
269
|
type ListIdeasParams struct {
|
|
246
270
|
Page *int `form:"page,omitempty" json:"page,omitempty"`
|
|
247
271
|
Size *int `form:"size,omitempty" json:"size,omitempty"`
|
|
272
|
+
|
|
273
|
+
// Org Closed set; an invalid value is 422 INVALID_ENUM (never ignored). Validated in the handler.
|
|
274
|
+
Org *string `form:"org,omitempty" json:"org,omitempty"`
|
|
275
|
+
Category *string `form:"category,omitempty" json:"category,omitempty"`
|
|
276
|
+
Direction *string `form:"direction,omitempty" json:"direction,omitempty"`
|
|
277
|
+
LeaderEmpNo *string `form:"leaderEmpNo,omitempty" json:"leaderEmpNo,omitempty"`
|
|
278
|
+
|
|
279
|
+
// CreatedFrom ISO 8601 instant with offset or Z; inclusive. Validated in the handler.
|
|
280
|
+
CreatedFrom *string `form:"createdFrom,omitempty" json:"createdFrom,omitempty"`
|
|
281
|
+
|
|
282
|
+
// CreatedTo ISO 8601 instant with offset or Z; exclusive. Validated in the handler.
|
|
283
|
+
CreatedTo *string `form:"createdTo,omitempty" json:"createdTo,omitempty"`
|
|
284
|
+
|
|
285
|
+
// Q Case-insensitive "name contains"; `%` and `_` are literal.
|
|
286
|
+
Q *string `form:"q,omitempty" json:"q,omitempty"`
|
|
287
|
+
|
|
288
|
+
// Sort createdAt (default) or name; anything else is 422 INVALID_ENUM. Validated in the handler.
|
|
289
|
+
Sort *string `form:"sort,omitempty" json:"sort,omitempty"`
|
|
290
|
+
|
|
291
|
+
// Order asc or desc (default desc for createdAt, asc for name); anything else is 422 INVALID_ENUM. Validated in the handler.
|
|
292
|
+
Order *string `form:"order,omitempty" json:"order,omitempty"`
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// CreateIdeaParams defines parameters for CreateIdea.
|
|
296
|
+
type CreateIdeaParams struct {
|
|
297
|
+
// XCSRFToken Session-bound token from GET /auth/csrf. Mandatory on every write, but enforced by the CSRF middleware (403 CSRF_INVALID), so the generated wrapper must not reject its absence first (400).
|
|
298
|
+
XCSRFToken *CsrfToken `json:"X-CSRF-Token,omitempty"`
|
|
248
299
|
}
|
|
249
300
|
|
|
250
301
|
// LoginJSONRequestBody defines body for Login for application/json ContentType.
|
|
@@ -253,6 +304,9 @@ type LoginJSONRequestBody = LoginRequest
|
|
|
253
304
|
// RegisterJSONRequestBody defines body for Register for application/json ContentType.
|
|
254
305
|
type RegisterJSONRequestBody = RegisterRequest
|
|
255
306
|
|
|
307
|
+
// CreateIdeaMultipartRequestBody defines body for CreateIdea for multipart/form-data ContentType.
|
|
308
|
+
type CreateIdeaMultipartRequestBody = CreateIdeaForm
|
|
309
|
+
|
|
256
310
|
// ServerInterface represents all server handlers.
|
|
257
311
|
type ServerInterface interface {
|
|
258
312
|
// GetCsrfToken Session-bound CSRF token (anonymous before login)
|
|
@@ -276,9 +330,15 @@ type ServerInterface interface {
|
|
|
276
330
|
// ListIdeas List Ideas visible to the caller
|
|
277
331
|
// (GET /ideas)
|
|
278
332
|
ListIdeas(w http.ResponseWriter, r *http.Request, params ListIdeasParams)
|
|
333
|
+
// CreateIdea Create an Idea with its slides (User only)
|
|
334
|
+
// (POST /ideas)
|
|
335
|
+
CreateIdea(w http.ResponseWriter, r *http.Request, params CreateIdeaParams)
|
|
279
336
|
// GetIdea Idea detail
|
|
280
337
|
// (GET /ideas/{id})
|
|
281
338
|
GetIdea(w http.ResponseWriter, r *http.Request, id IdeaId)
|
|
339
|
+
// DownloadSlides Download the Idea's slides (Leader, Member, Admin)
|
|
340
|
+
// (GET /ideas/{id}/slides)
|
|
341
|
+
DownloadSlides(w http.ResponseWriter, r *http.Request, id IdeaId)
|
|
282
342
|
}
|
|
283
343
|
|
|
284
344
|
// Unimplemented server implementation that returns http.StatusNotImplemented for each endpoint.
|
|
@@ -327,12 +387,24 @@ func (_ Unimplemented) ListIdeas(w http.ResponseWriter, r *http.Request, params
|
|
|
327
387
|
w.WriteHeader(http.StatusNotImplemented)
|
|
328
388
|
}
|
|
329
389
|
|
|
390
|
+
// CreateIdea Create an Idea with its slides (User only)
|
|
391
|
+
// (POST /ideas)
|
|
392
|
+
func (_ Unimplemented) CreateIdea(w http.ResponseWriter, r *http.Request, params CreateIdeaParams) {
|
|
393
|
+
w.WriteHeader(http.StatusNotImplemented)
|
|
394
|
+
}
|
|
395
|
+
|
|
330
396
|
// GetIdea Idea detail
|
|
331
397
|
// (GET /ideas/{id})
|
|
332
398
|
func (_ Unimplemented) GetIdea(w http.ResponseWriter, r *http.Request, id IdeaId) {
|
|
333
399
|
w.WriteHeader(http.StatusNotImplemented)
|
|
334
400
|
}
|
|
335
401
|
|
|
402
|
+
// DownloadSlides Download the Idea's slides (Leader, Member, Admin)
|
|
403
|
+
// (GET /ideas/{id}/slides)
|
|
404
|
+
func (_ Unimplemented) DownloadSlides(w http.ResponseWriter, r *http.Request, id IdeaId) {
|
|
405
|
+
w.WriteHeader(http.StatusNotImplemented)
|
|
406
|
+
}
|
|
407
|
+
|
|
336
408
|
// ServerInterfaceWrapper converts contexts to parameters.
|
|
337
409
|
type ServerInterfaceWrapper struct {
|
|
338
410
|
Handler ServerInterface
|
|
@@ -542,6 +614,123 @@ func (siw *ServerInterfaceWrapper) ListIdeas(w http.ResponseWriter, r *http.Requ
|
|
|
542
614
|
return
|
|
543
615
|
}
|
|
544
616
|
|
|
617
|
+
// ------------- Optional query parameter "org" -------------
|
|
618
|
+
|
|
619
|
+
err = runtime.BindQueryParameterWithOptions("form", true, false, "org", r.URL.Query(), ¶ms.Org, runtime.BindQueryParameterOptions{Type: "string", Format: ""})
|
|
620
|
+
if err != nil {
|
|
621
|
+
var requiredError *runtime.RequiredParameterError
|
|
622
|
+
if errors.As(err, &requiredError) {
|
|
623
|
+
siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "org"})
|
|
624
|
+
} else {
|
|
625
|
+
siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "org", Err: err})
|
|
626
|
+
}
|
|
627
|
+
return
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// ------------- Optional query parameter "category" -------------
|
|
631
|
+
|
|
632
|
+
err = runtime.BindQueryParameterWithOptions("form", true, false, "category", r.URL.Query(), ¶ms.Category, runtime.BindQueryParameterOptions{Type: "string", Format: ""})
|
|
633
|
+
if err != nil {
|
|
634
|
+
var requiredError *runtime.RequiredParameterError
|
|
635
|
+
if errors.As(err, &requiredError) {
|
|
636
|
+
siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "category"})
|
|
637
|
+
} else {
|
|
638
|
+
siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "category", Err: err})
|
|
639
|
+
}
|
|
640
|
+
return
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
// ------------- Optional query parameter "direction" -------------
|
|
644
|
+
|
|
645
|
+
err = runtime.BindQueryParameterWithOptions("form", true, false, "direction", r.URL.Query(), ¶ms.Direction, runtime.BindQueryParameterOptions{Type: "string", Format: ""})
|
|
646
|
+
if err != nil {
|
|
647
|
+
var requiredError *runtime.RequiredParameterError
|
|
648
|
+
if errors.As(err, &requiredError) {
|
|
649
|
+
siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "direction"})
|
|
650
|
+
} else {
|
|
651
|
+
siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "direction", Err: err})
|
|
652
|
+
}
|
|
653
|
+
return
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
// ------------- Optional query parameter "leaderEmpNo" -------------
|
|
657
|
+
|
|
658
|
+
err = runtime.BindQueryParameterWithOptions("form", true, false, "leaderEmpNo", r.URL.Query(), ¶ms.LeaderEmpNo, runtime.BindQueryParameterOptions{Type: "string", Format: ""})
|
|
659
|
+
if err != nil {
|
|
660
|
+
var requiredError *runtime.RequiredParameterError
|
|
661
|
+
if errors.As(err, &requiredError) {
|
|
662
|
+
siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "leaderEmpNo"})
|
|
663
|
+
} else {
|
|
664
|
+
siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "leaderEmpNo", Err: err})
|
|
665
|
+
}
|
|
666
|
+
return
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
// ------------- Optional query parameter "createdFrom" -------------
|
|
670
|
+
|
|
671
|
+
err = runtime.BindQueryParameterWithOptions("form", true, false, "createdFrom", r.URL.Query(), ¶ms.CreatedFrom, runtime.BindQueryParameterOptions{Type: "string", Format: ""})
|
|
672
|
+
if err != nil {
|
|
673
|
+
var requiredError *runtime.RequiredParameterError
|
|
674
|
+
if errors.As(err, &requiredError) {
|
|
675
|
+
siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "createdFrom"})
|
|
676
|
+
} else {
|
|
677
|
+
siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "createdFrom", Err: err})
|
|
678
|
+
}
|
|
679
|
+
return
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
// ------------- Optional query parameter "createdTo" -------------
|
|
683
|
+
|
|
684
|
+
err = runtime.BindQueryParameterWithOptions("form", true, false, "createdTo", r.URL.Query(), ¶ms.CreatedTo, runtime.BindQueryParameterOptions{Type: "string", Format: ""})
|
|
685
|
+
if err != nil {
|
|
686
|
+
var requiredError *runtime.RequiredParameterError
|
|
687
|
+
if errors.As(err, &requiredError) {
|
|
688
|
+
siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "createdTo"})
|
|
689
|
+
} else {
|
|
690
|
+
siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "createdTo", Err: err})
|
|
691
|
+
}
|
|
692
|
+
return
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
// ------------- Optional query parameter "q" -------------
|
|
696
|
+
|
|
697
|
+
err = runtime.BindQueryParameterWithOptions("form", true, false, "q", r.URL.Query(), ¶ms.Q, runtime.BindQueryParameterOptions{Type: "string", Format: ""})
|
|
698
|
+
if err != nil {
|
|
699
|
+
var requiredError *runtime.RequiredParameterError
|
|
700
|
+
if errors.As(err, &requiredError) {
|
|
701
|
+
siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "q"})
|
|
702
|
+
} else {
|
|
703
|
+
siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "q", Err: err})
|
|
704
|
+
}
|
|
705
|
+
return
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
// ------------- Optional query parameter "sort" -------------
|
|
709
|
+
|
|
710
|
+
err = runtime.BindQueryParameterWithOptions("form", true, false, "sort", r.URL.Query(), ¶ms.Sort, runtime.BindQueryParameterOptions{Type: "string", Format: ""})
|
|
711
|
+
if err != nil {
|
|
712
|
+
var requiredError *runtime.RequiredParameterError
|
|
713
|
+
if errors.As(err, &requiredError) {
|
|
714
|
+
siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "sort"})
|
|
715
|
+
} else {
|
|
716
|
+
siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "sort", Err: err})
|
|
717
|
+
}
|
|
718
|
+
return
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
// ------------- Optional query parameter "order" -------------
|
|
722
|
+
|
|
723
|
+
err = runtime.BindQueryParameterWithOptions("form", true, false, "order", r.URL.Query(), ¶ms.Order, runtime.BindQueryParameterOptions{Type: "string", Format: ""})
|
|
724
|
+
if err != nil {
|
|
725
|
+
var requiredError *runtime.RequiredParameterError
|
|
726
|
+
if errors.As(err, &requiredError) {
|
|
727
|
+
siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "order"})
|
|
728
|
+
} else {
|
|
729
|
+
siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "order", Err: err})
|
|
730
|
+
}
|
|
731
|
+
return
|
|
732
|
+
}
|
|
733
|
+
|
|
545
734
|
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
546
735
|
siw.Handler.ListIdeas(w, r, params)
|
|
547
736
|
}))
|
|
@@ -553,6 +742,47 @@ func (siw *ServerInterfaceWrapper) ListIdeas(w http.ResponseWriter, r *http.Requ
|
|
|
553
742
|
handler.ServeHTTP(w, r)
|
|
554
743
|
}
|
|
555
744
|
|
|
745
|
+
// CreateIdea operation middleware
|
|
746
|
+
func (siw *ServerInterfaceWrapper) CreateIdea(w http.ResponseWriter, r *http.Request) {
|
|
747
|
+
|
|
748
|
+
var err error
|
|
749
|
+
_ = err
|
|
750
|
+
|
|
751
|
+
// Parameter object where we will unmarshal all parameters from the context
|
|
752
|
+
var params CreateIdeaParams
|
|
753
|
+
|
|
754
|
+
headers := r.Header
|
|
755
|
+
|
|
756
|
+
// ------------- Optional header parameter "X-CSRF-Token" -------------
|
|
757
|
+
if valueList, found := headers[http.CanonicalHeaderKey("X-CSRF-Token")]; found {
|
|
758
|
+
var XCSRFToken CsrfToken
|
|
759
|
+
n := len(valueList)
|
|
760
|
+
if n != 1 {
|
|
761
|
+
siw.ErrorHandlerFunc(w, r, &TooManyValuesForParamError{ParamName: "X-CSRF-Token", Count: n})
|
|
762
|
+
return
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
err = runtime.BindStyledParameterWithOptions("simple", "X-CSRF-Token", valueList[0], &XCSRFToken, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationHeader, Explode: false, Required: false, Type: "string", Format: ""})
|
|
766
|
+
if err != nil {
|
|
767
|
+
siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "X-CSRF-Token", Err: err})
|
|
768
|
+
return
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
params.XCSRFToken = &XCSRFToken
|
|
772
|
+
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
776
|
+
siw.Handler.CreateIdea(w, r, params)
|
|
777
|
+
}))
|
|
778
|
+
|
|
779
|
+
for _, middleware := range siw.HandlerMiddlewares {
|
|
780
|
+
handler = middleware(handler)
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
handler.ServeHTTP(w, r)
|
|
784
|
+
}
|
|
785
|
+
|
|
556
786
|
// GetIdea operation middleware
|
|
557
787
|
func (siw *ServerInterfaceWrapper) GetIdea(w http.ResponseWriter, r *http.Request) {
|
|
558
788
|
|
|
@@ -579,6 +809,32 @@ func (siw *ServerInterfaceWrapper) GetIdea(w http.ResponseWriter, r *http.Reques
|
|
|
579
809
|
handler.ServeHTTP(w, r)
|
|
580
810
|
}
|
|
581
811
|
|
|
812
|
+
// DownloadSlides operation middleware
|
|
813
|
+
func (siw *ServerInterfaceWrapper) DownloadSlides(w http.ResponseWriter, r *http.Request) {
|
|
814
|
+
|
|
815
|
+
var err error
|
|
816
|
+
_ = err
|
|
817
|
+
|
|
818
|
+
// ------------- Path parameter "id" -------------
|
|
819
|
+
var id IdeaId
|
|
820
|
+
|
|
821
|
+
err = runtime.BindStyledParameterWithOptions("simple", "id", chi.URLParam(r, "id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "uuid", ValueIsUnescaped: r.URL.RawPath == ""})
|
|
822
|
+
if err != nil {
|
|
823
|
+
siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err})
|
|
824
|
+
return
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
828
|
+
siw.Handler.DownloadSlides(w, r, id)
|
|
829
|
+
}))
|
|
830
|
+
|
|
831
|
+
for _, middleware := range siw.HandlerMiddlewares {
|
|
832
|
+
handler = middleware(handler)
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
handler.ServeHTTP(w, r)
|
|
836
|
+
}
|
|
837
|
+
|
|
582
838
|
type UnescapedCookieParamError struct {
|
|
583
839
|
ParamName string
|
|
584
840
|
Err error
|
|
@@ -713,9 +969,15 @@ func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handl
|
|
|
713
969
|
r.Group(func(r chi.Router) {
|
|
714
970
|
r.Get(options.BaseURL+"/ideas", wrapper.ListIdeas)
|
|
715
971
|
})
|
|
972
|
+
r.Group(func(r chi.Router) {
|
|
973
|
+
r.Post(options.BaseURL+"/ideas", wrapper.CreateIdea)
|
|
974
|
+
})
|
|
716
975
|
r.Group(func(r chi.Router) {
|
|
717
976
|
r.Get(options.BaseURL+"/ideas/{id}", wrapper.GetIdea)
|
|
718
977
|
})
|
|
978
|
+
r.Group(func(r chi.Router) {
|
|
979
|
+
r.Get(options.BaseURL+"/ideas/{id}/slides", wrapper.DownloadSlides)
|
|
980
|
+
})
|
|
719
981
|
|
|
720
982
|
return r
|
|
721
983
|
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
package store
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"database/sql"
|
|
6
|
+
"errors"
|
|
7
|
+
"fmt"
|
|
8
|
+
"time"
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
// IPPolicy is the per-IP sliding-window rule (PRD §6.5).
|
|
12
|
+
type IPPolicy struct {
|
|
13
|
+
MaxFailures int
|
|
14
|
+
WindowSeconds int
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// RecordIPFailure stores one failed attempt of ip at database time.
|
|
18
|
+
func RecordIPFailure(ctx context.Context, db *sql.DB, ip string) error {
|
|
19
|
+
if _, err := db.ExecContext(ctx,
|
|
20
|
+
`INSERT INTO login_attempt (ip, attempted_at) VALUES (?, NOW(6))`, ip); err != nil {
|
|
21
|
+
return fmt.Errorf("record login attempt: %w", err)
|
|
22
|
+
}
|
|
23
|
+
return nil
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// IPRetryAfter returns how long ip stays over the limit, 0 when it is not.
|
|
27
|
+
// The address is over the limit while at least MaxFailures failures lie inside
|
|
28
|
+
// the window; it is free again when the MaxFailures-th newest one leaves it,
|
|
29
|
+
// so that row's age decides (with exactly MaxFailures rows this is the oldest).
|
|
30
|
+
func IPRetryAfter(ctx context.Context, db *sql.DB, ip string, p IPPolicy) (time.Duration, error) {
|
|
31
|
+
var us int64
|
|
32
|
+
err := db.QueryRowContext(ctx,
|
|
33
|
+
`SELECT TIMESTAMPDIFF(MICROSECOND, NOW(6), attempted_at + INTERVAL ? SECOND)
|
|
34
|
+
FROM login_attempt
|
|
35
|
+
WHERE ip = ? AND attempted_at > NOW(6) - INTERVAL ? SECOND
|
|
36
|
+
ORDER BY attempted_at DESC
|
|
37
|
+
LIMIT 1 OFFSET ?`, p.WindowSeconds, ip, p.WindowSeconds, p.MaxFailures-1).Scan(&us)
|
|
38
|
+
if errors.Is(err, sql.ErrNoRows) {
|
|
39
|
+
return 0, nil
|
|
40
|
+
}
|
|
41
|
+
if err != nil {
|
|
42
|
+
return 0, fmt.Errorf("read login attempts: %w", err)
|
|
43
|
+
}
|
|
44
|
+
return time.Duration(us) * time.Microsecond, nil
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// DeleteOldAttempts removes at most limit login_attempt rows outside the
|
|
48
|
+
// window; IPRetryAfter only reads rows inside it.
|
|
49
|
+
func DeleteOldAttempts(ctx context.Context, db *sql.DB, p IPPolicy, limit int) (int64, error) {
|
|
50
|
+
return deleteRows(ctx, db, "purge login attempts",
|
|
51
|
+
`DELETE FROM login_attempt WHERE attempted_at <= NOW(6) - INTERVAL ? SECOND LIMIT ?`,
|
|
52
|
+
p.WindowSeconds, limit)
|
|
53
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
package store
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"database/sql"
|
|
6
|
+
"errors"
|
|
7
|
+
"fmt"
|
|
8
|
+
"time"
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
// LockPolicy is the key-lock rule (PRD §6.5), in seconds and failures.
|
|
12
|
+
type LockPolicy struct {
|
|
13
|
+
MaxFailures int
|
|
14
|
+
WindowSeconds int // a failure older than this no longer counts as consecutive
|
|
15
|
+
LockSeconds int // fixed lock length
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// RecordFailure counts one failed attempt for key in ONE statement on database
|
|
19
|
+
// time. The assignments run left to right in MariaDB, so later columns see the
|
|
20
|
+
// new fail_count. A lock that is still active is never extended or re-counted
|
|
21
|
+
// (a concurrent attempt that passed Check before the lock was set); an expired
|
|
22
|
+
// lock or a stale window starts again at 1.
|
|
23
|
+
func RecordFailure(ctx context.Context, db *sql.DB, key string, p LockPolicy) error {
|
|
24
|
+
_, err := db.ExecContext(ctx,
|
|
25
|
+
`INSERT INTO login_lock (lock_key, fail_count, last_fail_at, locked_until)
|
|
26
|
+
VALUES (?, 1, NOW(6), NULL)
|
|
27
|
+
ON DUPLICATE KEY UPDATE
|
|
28
|
+
fail_count = IF(locked_until > NOW(6), fail_count,
|
|
29
|
+
IF(locked_until IS NOT NULL OR last_fail_at <= NOW(6) - INTERVAL ? SECOND, 1, fail_count + 1)),
|
|
30
|
+
locked_until = CASE WHEN locked_until > NOW(6) THEN locked_until
|
|
31
|
+
WHEN fail_count >= ? THEN NOW(6) + INTERVAL ? SECOND
|
|
32
|
+
ELSE NULL END,
|
|
33
|
+
last_fail_at = NOW(6)`,
|
|
34
|
+
key, p.WindowSeconds, p.MaxFailures, p.LockSeconds)
|
|
35
|
+
if err != nil {
|
|
36
|
+
return fmt.Errorf("record login failure: %w", err)
|
|
37
|
+
}
|
|
38
|
+
return nil
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// LockRemaining returns how long key stays locked, 0 when it is not locked.
|
|
42
|
+
func LockRemaining(ctx context.Context, db *sql.DB, key string) (time.Duration, error) {
|
|
43
|
+
var us sql.NullInt64
|
|
44
|
+
err := db.QueryRowContext(ctx,
|
|
45
|
+
`SELECT TIMESTAMPDIFF(MICROSECOND, NOW(6), locked_until)
|
|
46
|
+
FROM login_lock WHERE lock_key = ? AND locked_until > NOW(6)`, key).Scan(&us)
|
|
47
|
+
if errors.Is(err, sql.ErrNoRows) {
|
|
48
|
+
return 0, nil
|
|
49
|
+
}
|
|
50
|
+
if err != nil {
|
|
51
|
+
return 0, fmt.Errorf("read login lock: %w", err)
|
|
52
|
+
}
|
|
53
|
+
return time.Duration(us.Int64) * time.Microsecond, nil
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ClearFailures resets the counter after a successful login. An active lock is
|
|
57
|
+
// left alone (a success cannot shorten it).
|
|
58
|
+
func ClearFailures(ctx context.Context, db *sql.DB, key string) error {
|
|
59
|
+
_, err := db.ExecContext(ctx,
|
|
60
|
+
`DELETE FROM login_lock WHERE lock_key = ? AND (locked_until IS NULL OR locked_until <= NOW(6))`, key)
|
|
61
|
+
if err != nil {
|
|
62
|
+
return fmt.Errorf("clear login failures: %w", err)
|
|
63
|
+
}
|
|
64
|
+
return nil
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// DeleteStaleLocks removes at most limit login_lock rows that no longer
|
|
68
|
+
// matter: no active lock and the last failure outside the window. For such a
|
|
69
|
+
// row RecordFailure would start again at 1, exactly as for a missing row.
|
|
70
|
+
func DeleteStaleLocks(ctx context.Context, db *sql.DB, p LockPolicy, limit int) (int64, error) {
|
|
71
|
+
return deleteRows(ctx, db, "purge login locks",
|
|
72
|
+
`DELETE FROM login_lock
|
|
73
|
+
WHERE last_fail_at <= NOW(6) - INTERVAL ? SECOND
|
|
74
|
+
AND (locked_until IS NULL OR locked_until <= NOW(6))
|
|
75
|
+
LIMIT ?`,
|
|
76
|
+
p.WindowSeconds, limit)
|
|
77
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
package store
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"database/sql"
|
|
6
|
+
"fmt"
|
|
7
|
+
"time"
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
// ClaimThrottleAudit reports whether the caller may write the THROTTLED audit
|
|
11
|
+
// row of (scope, subject): true for the first throttled attempt of a window,
|
|
12
|
+
// false while an earlier claim is still running. A winning claim holds until
|
|
13
|
+
// database now + hold. The claim is atomic, so concurrent attempts on several
|
|
14
|
+
// replicas produce exactly one winner.
|
|
15
|
+
func ClaimThrottleAudit(ctx context.Context, db *sql.DB, scope, subject string, hold time.Duration) (bool, error) {
|
|
16
|
+
us := hold.Microseconds()
|
|
17
|
+
res, err := db.ExecContext(ctx,
|
|
18
|
+
`UPDATE login_throttle_audit SET audited_until = NOW(6) + INTERVAL ? MICROSECOND
|
|
19
|
+
WHERE scope = ? AND subject = ? AND audited_until <= NOW(6)`, us, scope, subject)
|
|
20
|
+
if err != nil {
|
|
21
|
+
return false, fmt.Errorf("claim throttle audit: %w", err)
|
|
22
|
+
}
|
|
23
|
+
if n, _ := res.RowsAffected(); n > 0 {
|
|
24
|
+
return true, nil
|
|
25
|
+
}
|
|
26
|
+
res, err = db.ExecContext(ctx,
|
|
27
|
+
`INSERT IGNORE INTO login_throttle_audit (scope, subject, audited_until)
|
|
28
|
+
VALUES (?, ?, NOW(6) + INTERVAL ? MICROSECOND)`, scope, subject, us)
|
|
29
|
+
if err != nil {
|
|
30
|
+
return false, fmt.Errorf("claim throttle audit: %w", err)
|
|
31
|
+
}
|
|
32
|
+
n, _ := res.RowsAffected()
|
|
33
|
+
return n > 0, nil
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// DeleteExpiredThrottleAudits removes at most limit claims that have run out.
|
|
37
|
+
// ClaimThrottleAudit treats a missing row like an expired one (the next
|
|
38
|
+
// throttled attempt wins), so deleting it changes no outcome.
|
|
39
|
+
func DeleteExpiredThrottleAudits(ctx context.Context, db *sql.DB, limit int) (int64, error) {
|
|
40
|
+
return deleteRows(ctx, db, "purge throttle audits",
|
|
41
|
+
`DELETE FROM login_throttle_audit WHERE audited_until <= NOW(6) LIMIT ?`, limit)
|
|
42
|
+
}
|
|
@@ -99,19 +99,27 @@ func DeleteByUser(ctx context.Context, db *sql.DB, userID uuid.UUID) error {
|
|
|
99
99
|
return nil
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
-
// DeleteExpired removes sessions past either timeout and
|
|
103
|
-
|
|
104
|
-
|
|
102
|
+
// DeleteExpired removes at most limit sessions past either timeout and
|
|
103
|
+
// returns how many. The condition is the exact complement of Touch's, so a row
|
|
104
|
+
// it deletes could no longer be validated anyway.
|
|
105
|
+
func DeleteExpired(ctx context.Context, db *sql.DB, l Limits, limit int) (int64, error) {
|
|
106
|
+
return deleteRows(ctx, db, "purge sessions",
|
|
105
107
|
`DELETE FROM session
|
|
106
108
|
WHERE last_access_at <= NOW(6) - INTERVAL ? SECOND
|
|
107
|
-
OR created_at <= NOW(6) - INTERVAL ? SECOND
|
|
108
|
-
|
|
109
|
+
OR created_at <= NOW(6) - INTERVAL ? SECOND
|
|
110
|
+
LIMIT ?`,
|
|
111
|
+
l.IdleSeconds, l.AbsoluteSeconds, limit)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// deleteRows runs one DELETE and returns the affected row count.
|
|
115
|
+
func deleteRows(ctx context.Context, db *sql.DB, what, query string, args ...any) (int64, error) {
|
|
116
|
+
res, err := db.ExecContext(ctx, query, args...)
|
|
109
117
|
if err != nil {
|
|
110
|
-
return 0, fmt.Errorf("
|
|
118
|
+
return 0, fmt.Errorf("%s: %w", what, err)
|
|
111
119
|
}
|
|
112
120
|
n, err := res.RowsAffected()
|
|
113
121
|
if err != nil {
|
|
114
|
-
return 0, fmt.Errorf("
|
|
122
|
+
return 0, fmt.Errorf("%s rows: %w", what, err)
|
|
115
123
|
}
|
|
116
124
|
return n, nil
|
|
117
125
|
}
|
|
@@ -33,15 +33,20 @@ const (
|
|
|
33
33
|
|
|
34
34
|
var empNoFormat = regexp.MustCompile(`^[0-9]{6}$`)
|
|
35
35
|
|
|
36
|
-
// Throttle is the login throttling hook (PRD §6.5)
|
|
37
|
-
// login_lock
|
|
38
|
-
//
|
|
36
|
+
// Throttle is the login throttling hook (PRD §6.5); DBThrottle implements it on
|
|
37
|
+
// login_lock. Errors fail the login closed (500): an unreachable counter must
|
|
38
|
+
// not disable the limit.
|
|
39
39
|
type Throttle interface {
|
|
40
40
|
// Check runs before any password work. A positive retryAfter means the
|
|
41
41
|
// attempt is throttled: it answers 429, runs no bcrypt and is not counted.
|
|
42
42
|
// key is the submitted employee-ID string as typed; implementations strip
|
|
43
43
|
// control characters and cut it to 32 characters.
|
|
44
44
|
Check(ctx context.Context, key string, ip netip.Addr) (retryAfter time.Duration, err error)
|
|
45
|
+
// AuditOnce is asked after Check reported a wait. It returns true for the
|
|
46
|
+
// first throttled attempt of a window and false for the rest, so a throttled
|
|
47
|
+
// key or address is audited once per window (PRD §6.11). Its error is not
|
|
48
|
+
// fatal: the attempt is audited anyway.
|
|
49
|
+
AuditOnce(ctx context.Context, key string, ip netip.Addr) (bool, error)
|
|
45
50
|
// Fail counts one failed attempt (also for unknown accounts).
|
|
46
51
|
Fail(ctx context.Context, key string, ip netip.Addr) error
|
|
47
52
|
// Succeed clears the key's counter.
|
|
@@ -54,6 +59,9 @@ type NoThrottle struct{}
|
|
|
54
59
|
// Check implements Throttle.
|
|
55
60
|
func (NoThrottle) Check(context.Context, string, netip.Addr) (time.Duration, error) { return 0, nil }
|
|
56
61
|
|
|
62
|
+
// AuditOnce implements Throttle.
|
|
63
|
+
func (NoThrottle) AuditOnce(context.Context, string, netip.Addr) (bool, error) { return true, nil }
|
|
64
|
+
|
|
57
65
|
// Fail implements Throttle.
|
|
58
66
|
func (NoThrottle) Fail(context.Context, string, netip.Addr) error { return nil }
|
|
59
67
|
|
|
@@ -122,7 +130,11 @@ func (l *Logins) Login(ctx context.Context, cmd LoginCommand, req Request) (Logg
|
|
|
122
130
|
if retry > 0 {
|
|
123
131
|
e := web.NewError(web.CodeLoginThrottled, "too many login attempts")
|
|
124
132
|
e.RetryAfter = retry
|
|
125
|
-
|
|
133
|
+
if first, err := l.Throttle.AuditOnce(ctx, cmd.EmpNo, req.IP); err != nil || first {
|
|
134
|
+
// An error audits anyway: a duplicate row beats a missing one.
|
|
135
|
+
return LoggedIn{}, loginRefused(e, audit.Throttled, "throttled", cmd.EmpNo)
|
|
136
|
+
}
|
|
137
|
+
return LoggedIn{}, e
|
|
126
138
|
}
|
|
127
139
|
|
|
128
140
|
var (
|
|
@@ -42,6 +42,9 @@ func (f *fakeThrottle) Check(_ context.Context, key string, _ netip.Addr) (time.
|
|
|
42
42
|
f.lastKey = key
|
|
43
43
|
return f.retry, f.err
|
|
44
44
|
}
|
|
45
|
+
func (f *fakeThrottle) AuditOnce(context.Context, string, netip.Addr) (bool, error) {
|
|
46
|
+
return true, nil
|
|
47
|
+
}
|
|
45
48
|
func (f *fakeThrottle) Fail(context.Context, string, netip.Addr) error {
|
|
46
49
|
f.mu.Lock()
|
|
47
50
|
defer f.mu.Unlock()
|
|
@@ -67,13 +70,23 @@ func newLoginEnv(t *testing.T) loginEnv {
|
|
|
67
70
|
}
|
|
68
71
|
|
|
69
72
|
func newLoginEnvLog(t *testing.T, log *slog.Logger) loginEnv {
|
|
73
|
+
t.Helper()
|
|
74
|
+
return newLoginEnvWith(t, log, nil)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// newLoginEnvWith wires mk(db) as the throttle; nil keeps the fake.
|
|
78
|
+
func newLoginEnvWith(t *testing.T, log *slog.Logger, mk func(*sql.DB) auth.Throttle) loginEnv {
|
|
70
79
|
t.Helper()
|
|
71
80
|
db := dbtest.New(t)
|
|
72
81
|
auditSvc := audit.New(db, nil)
|
|
73
82
|
errs := server.ErrorAdapter{Audit: auditSvc}
|
|
74
83
|
sessions := auth.New(db)
|
|
75
84
|
th := &fakeThrottle{}
|
|
76
|
-
|
|
85
|
+
var thr auth.Throttle = th
|
|
86
|
+
if mk != nil {
|
|
87
|
+
thr = mk(db)
|
|
88
|
+
}
|
|
89
|
+
logins, err := auth.NewLogins(sessions, auditSvc, thr)
|
|
77
90
|
if err != nil {
|
|
78
91
|
t.Fatal(err)
|
|
79
92
|
}
|