@johnnyboy11234/esg-idea-contest 0.2.0 → 0.2.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.
Files changed (45) hide show
  1. package/backend/api/openapi.yaml +44 -0
  2. package/backend/cmd/server/main.go +20 -2
  3. package/backend/db/migration/V004__session.sql +19 -0
  4. package/backend/go.mod +1 -1
  5. package/backend/internal/account/account_test.go +191 -0
  6. package/backend/internal/account/handler.go +58 -0
  7. package/backend/internal/account/integration_test.go +268 -0
  8. package/backend/internal/account/internal/store/store.go +35 -0
  9. package/backend/internal/account/service.go +153 -0
  10. package/backend/internal/account/validate.go +100 -0
  11. package/backend/internal/api/api.gen.go +82 -0
  12. package/backend/internal/audit/integration_test.go +1 -1
  13. package/backend/internal/auth/csrf.go +212 -0
  14. package/backend/internal/auth/csrf_test.go +281 -0
  15. package/backend/internal/auth/internal/store/session.go +111 -0
  16. package/backend/internal/auth/middleware.go +66 -0
  17. package/backend/internal/auth/middleware_test.go +153 -0
  18. package/backend/internal/auth/session.go +113 -0
  19. package/backend/internal/auth/session_integration_test.go +245 -0
  20. package/backend/internal/auth/session_test.go +36 -0
  21. package/backend/internal/server/api.go +12 -0
  22. package/backend/internal/server/errors.go +10 -25
  23. package/backend/internal/server/errors_test.go +18 -0
  24. package/backend/internal/server/public.go +35 -0
  25. package/backend/internal/server/public_test.go +73 -0
  26. package/backend/internal/server/server.go +7 -3
  27. package/backend/internal/shared/config/config.go +23 -4
  28. package/backend/internal/shared/config/config_test.go +14 -8
  29. package/backend/internal/shared/db/intx_test.go +25 -0
  30. package/backend/internal/shared/db/mysqlerr.go +26 -0
  31. package/backend/internal/shared/security/security.go +6 -2
  32. package/backend/internal/shared/security/security_test.go +6 -4
  33. package/backend/internal/shared/web/problem.go +6 -0
  34. package/backend/internal/shared/web/request.go +27 -0
  35. package/frontend/src/app/App.test.tsx +13 -0
  36. package/frontend/src/app/App.tsx +4 -0
  37. package/frontend/src/app/session.ts +28 -6
  38. package/frontend/src/shared/api/csrf.ts +51 -0
  39. package/frontend/src/shared/api/http.test.tsx +209 -0
  40. package/frontend/src/shared/api/http.ts +91 -0
  41. package/frontend/src/shared/api/index.ts +3 -0
  42. package/frontend/src/shared/text/index.ts +93 -0
  43. package/frontend/src/shared/text/text.test.ts +22 -0
  44. package/package.json +1 -1
  45. package/frontend/src/shared/text/.gitkeep +0 -0
@@ -41,6 +41,35 @@ paths:
41
41
  '204': { description: Logged out }
42
42
  '403': { $ref: '#/components/responses/Problem' }
43
43
  '500': { $ref: '#/components/responses/Problem' }
44
+ /auth/register:
45
+ post:
46
+ operationId: register
47
+ tags: [auth]
48
+ summary: Self-service registration (no session is created)
49
+ description: >
50
+ Validation order: format, password policy, uniqueness (PRD 6.3). Sets no cookie.
51
+ security: []
52
+ parameters:
53
+ - $ref: '#/components/parameters/CsrfToken'
54
+ requestBody:
55
+ required: true
56
+ content:
57
+ application/json:
58
+ schema: { $ref: '#/components/schemas/RegisterRequest' }
59
+ responses:
60
+ '201':
61
+ description: Account created; log in separately
62
+ content:
63
+ application/json:
64
+ schema: { $ref: '#/components/schemas/RegisterResponse' }
65
+ '400': { $ref: '#/components/responses/Problem' }
66
+ '403': { $ref: '#/components/responses/Problem' }
67
+ '409': { $ref: '#/components/responses/Problem' }
68
+ '413': { $ref: '#/components/responses/Problem' }
69
+ '415': { $ref: '#/components/responses/Problem' }
70
+ '422': { $ref: '#/components/responses/Problem' }
71
+ '500': { $ref: '#/components/responses/Problem' }
72
+ '503': { $ref: '#/components/responses/Problem' }
44
73
  /deadline:
45
74
  get:
46
75
  operationId: getDeadline
@@ -125,6 +154,21 @@ components:
125
154
  required: [token]
126
155
  properties:
127
156
  token: { type: string }
157
+ RegisterRequest:
158
+ type: object
159
+ required: [empNo, name, email, password]
160
+ properties:
161
+ empNo: { type: string, description: 'Exactly 6 ASCII digits' }
162
+ name: { type: string, description: 1-50 code points after clean-up }
163
+ email: { type: string, description: 'Printable ASCII with exactly one @, at most 254 characters; stored lower-cased' }
164
+ password: { type: string, format: password }
165
+ RegisterResponse:
166
+ type: object
167
+ required: [empNo, name, email]
168
+ properties:
169
+ empNo: { type: string }
170
+ name: { type: string }
171
+ email: { type: string }
128
172
  Problem:
129
173
  type: object
130
174
  required: [type, title, status, code]
@@ -13,6 +13,9 @@ import (
13
13
 
14
14
  _ "github.com/go-sql-driver/mysql"
15
15
 
16
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/account"
17
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/audit"
18
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/auth"
16
19
  "github.com/brian-huanggg/AwardRegistration/backend/internal/deadline"
17
20
  "github.com/brian-huanggg/AwardRegistration/backend/internal/server"
18
21
  "github.com/brian-huanggg/AwardRegistration/backend/internal/shared/config"
@@ -42,8 +45,23 @@ func run() error {
42
45
  }
43
46
  defer func() { _ = db.Close() }()
44
47
 
45
- si := server.API{Deadline: deadline.Handler{Store: deadline.SQLStore{DB: db}}}
48
+ auditSvc := audit.New(db, log)
49
+ errs := server.ErrorAdapter{Audit: auditSvc, Log: log}
50
+ csrf, err := auth.NewCSRF(cfg.CSRFKey, cfg.CSRFKeyPrevious)
51
+ if err != nil {
52
+ return err
53
+ }
54
+ si := server.API{
55
+ Auth: auth.Handler{CSRF: csrf, Log: log},
56
+ Account: account.Handler{
57
+ Service: &account.Service{DB: db, Audit: auditSvc, Policy: account.StubPolicy{}},
58
+ Fail: errs.Write,
59
+ },
60
+ Deadline: deadline.Handler{Store: deadline.SQLStore{DB: db}},
61
+ }
46
62
 
47
63
  var ready atomic.Bool
48
- return server.Run(ctx, cfg, log, server.NewPublic(log, server.Mount(si)), &ready, server.DBCheck(db))
64
+ return server.Run(ctx, cfg, log, server.NewPublic(log, server.Mount(si),
65
+ auth.Middleware(auth.New(db), server.IsPublic, log),
66
+ csrf.Middleware(errs.Write, server.ActionFor)), &ready, server.DBCheck(db))
49
67
  }
@@ -0,0 +1,19 @@
1
+ -- V004 (B1-07): server-side sessions (PRD 6.5). Only SHA-256(session ID) is stored, never the ID itself,
2
+ -- so a database read does not yield usable cookies. The role is NOT stored: it is read from app_user on
3
+ -- every request. Times are database time in UTC (Architecture principle 4).
4
+ -- idle timeout 30 min since last_access_at (refreshed at most once per 60 s, OI-11)
5
+ -- absolute timeout 8 h since created_at
6
+ -- Deleting or disabling a user ends their sessions: ON DELETE CASCADE here, SessionRevoker for disable.
7
+
8
+ CREATE TABLE session (
9
+ id_hash BINARY(32) NOT NULL,
10
+ user_id UUID NOT NULL,
11
+ created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
12
+ last_access_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
13
+ PRIMARY KEY (id_hash),
14
+ KEY ix_session_user (user_id),
15
+ -- Purge scans by age.
16
+ KEY ix_session_created (created_at),
17
+ KEY ix_session_last_access (last_access_at),
18
+ CONSTRAINT fk_session_user FOREIGN KEY (user_id) REFERENCES app_user (id) ON DELETE CASCADE
19
+ ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_bin;
package/backend/go.mod CHANGED
@@ -12,6 +12,7 @@ require (
12
12
  github.com/oapi-codegen/runtime v1.8.0
13
13
  github.com/santhosh-tekuri/jsonschema/v6 v6.0.3
14
14
  github.com/testcontainers/testcontainers-go/modules/mariadb v0.45.0
15
+ golang.org/x/crypto v0.58.0
15
16
  golang.org/x/text v0.43.0
16
17
  )
17
18
 
@@ -73,7 +74,6 @@ require (
73
74
  go.opentelemetry.io/otel/metric v1.44.0 // indirect
74
75
  go.opentelemetry.io/otel/trace v1.44.0 // indirect
75
76
  go.yaml.in/yaml/v3 v3.0.5 // indirect
76
- golang.org/x/crypto v0.58.0 // indirect
77
77
  golang.org/x/mod v0.41.0 // indirect
78
78
  golang.org/x/sync v0.24.0 // indirect
79
79
  golang.org/x/sys v0.49.0 // indirect
@@ -0,0 +1,191 @@
1
+ package account_test
2
+
3
+ import (
4
+ "context"
5
+ "errors"
6
+ "net/http"
7
+ "net/http/httptest"
8
+ "strings"
9
+ "testing"
10
+
11
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/account"
12
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/shared/web"
13
+ )
14
+
15
+ // A Service with no database: every case here must be rejected before any I/O.
16
+ func validate(t *testing.T, cmd account.RegisterCommand) *web.Error {
17
+ t.Helper()
18
+ svc := &account.Service{Policy: account.StubPolicy{}}
19
+ _, err := svc.Register(context.Background(), cmd, account.Request{})
20
+ var e *web.Error
21
+ if !errors.As(err, &e) {
22
+ t.Fatalf("want *web.Error, got %v", err)
23
+ }
24
+ return e
25
+ }
26
+
27
+ func good() account.RegisterCommand {
28
+ return account.RegisterCommand{EmpNo: "089290", Name: "王小明", Email: "a@b.c", Password: "whatever-goes"}
29
+ }
30
+
31
+ func codes(e *web.Error) []string {
32
+ var out []string
33
+ for _, f := range e.Fields {
34
+ out = append(out, f.Field+":"+string(f.Code))
35
+ }
36
+ return out
37
+ }
38
+
39
+ func TestRegisterValidation(t *testing.T) {
40
+ cases := []struct {
41
+ name string
42
+ mut func(*account.RegisterCommand)
43
+ top web.ErrorCode
44
+ fields string
45
+ }{
46
+ {"empty emp no", func(c *account.RegisterCommand) { c.EmpNo = " " }, web.CodeValidationFailed, "empNo:REQUIRED"},
47
+ {"emp no 5 digits", func(c *account.RegisterCommand) { c.EmpNo = "12345" }, web.CodeValidationFailed, "empNo:INVALID_FORMAT"},
48
+ {"emp no 7 digits", func(c *account.RegisterCommand) { c.EmpNo = "1234567" }, web.CodeValidationFailed, "empNo:INVALID_FORMAT"},
49
+ {"emp no letter", func(c *account.RegisterCommand) { c.EmpNo = "12345a" }, web.CodeValidationFailed, "empNo:INVALID_FORMAT"},
50
+ {"emp no not trimmed", func(c *account.RegisterCommand) { c.EmpNo = " 12345" }, web.CodeValidationFailed, "empNo:INVALID_FORMAT"},
51
+ {"emp no fullwidth digits", func(c *account.RegisterCommand) { c.EmpNo = "123456" }, web.CodeValidationFailed, "empNo:INVALID_FORMAT"},
52
+ {"name empty after clean-up", func(c *account.RegisterCommand) { c.Name = "  \t" }, web.CodeValidationFailed, "name:REQUIRED"},
53
+ {"name forbidden char", func(c *account.RegisterCommand) { c.Name = "a/b" }, web.CodeInvalidNameChars, "name:INVALID_NAME_CHARS"},
54
+ {"name control char", func(c *account.RegisterCommand) { c.Name = "a\x00b" }, web.CodeValidationFailed, "name:INVALID_FORMAT"},
55
+ {"name 51 code points", func(c *account.RegisterCommand) { c.Name = strings.Repeat("名", 51) }, web.CodeValidationFailed, "name:OUT_OF_RANGE"},
56
+ {"email with space", func(c *account.RegisterCommand) { c.Email = "a b@x.com" }, web.CodeValidationFailed, "email:INVALID_FORMAT"},
57
+ {"email without @", func(c *account.RegisterCommand) { c.Email = "ab.com" }, web.CodeValidationFailed, "email:INVALID_FORMAT"},
58
+ {"email two @", func(c *account.RegisterCommand) { c.Email = "a@b@c" }, web.CodeValidationFailed, "email:INVALID_FORMAT"},
59
+ {"email empty local", func(c *account.RegisterCommand) { c.Email = "@b" }, web.CodeValidationFailed, "email:INVALID_FORMAT"},
60
+ {"email empty domain", func(c *account.RegisterCommand) { c.Email = "a@" }, web.CodeValidationFailed, "email:INVALID_FORMAT"},
61
+ {"email non-ASCII", func(c *account.RegisterCommand) { c.Email = "é@x.com" }, web.CodeValidationFailed, "email:INVALID_FORMAT"},
62
+ {"email empty", func(c *account.RegisterCommand) { c.Email = "" }, web.CodeValidationFailed, "email:REQUIRED"},
63
+ {"email 255 chars", func(c *account.RegisterCommand) { c.Email = strings.Repeat("a", 253) + "@b" }, web.CodeValidationFailed, "email:OUT_OF_RANGE"},
64
+ {"password empty", func(c *account.RegisterCommand) { c.Password = "" }, web.CodeValidationFailed, "password:REQUIRED"},
65
+ {"password beyond bcrypt", func(c *account.RegisterCommand) { c.Password = strings.Repeat("p", 73) }, web.CodeValidationFailed, "password:OUT_OF_RANGE"},
66
+ {"all fields in form order", func(c *account.RegisterCommand) {
67
+ c.EmpNo, c.Name, c.Email, c.Password = "x", "a:b", "nope", ""
68
+ }, web.CodeValidationFailed, "empNo:INVALID_FORMAT name:INVALID_NAME_CHARS email:INVALID_FORMAT password:REQUIRED"},
69
+ }
70
+ for _, tc := range cases {
71
+ t.Run(tc.name, func(t *testing.T) {
72
+ cmd := good()
73
+ tc.mut(&cmd)
74
+ e := validate(t, cmd)
75
+ if e.Status != 422 || e.Code != tc.top {
76
+ t.Errorf("status %d code %s, want 422 %s", e.Status, e.Code, tc.top)
77
+ }
78
+ if got := strings.Join(codes(e), " "); got != tc.fields {
79
+ t.Errorf("fields = %q, want %q", got, tc.fields)
80
+ }
81
+ // Every failure is an AUTH_REGISTER FAILED attempt for the adapter.
82
+ if e.AuditAction != "AUTH_REGISTER" || e.AuditOutcome != "FAILED" || e.AuditReason != "validation" {
83
+ t.Errorf("audit fields = %q %q %q", e.AuditAction, e.AuditOutcome, e.AuditReason)
84
+ }
85
+ })
86
+ }
87
+ }
88
+
89
+ func TestFailedAuditCarriesEmpNoOnlyWhenWellFormed(t *testing.T) {
90
+ cmd := good()
91
+ cmd.Name = ""
92
+ e := validate(t, cmd)
93
+ if e.AuditSubmittedEmpNo != "089290" || e.AuditDetail["empNo"] != "089290" {
94
+ t.Errorf("well-formed: submitted %q detail %v", e.AuditSubmittedEmpNo, e.AuditDetail)
95
+ }
96
+ cmd.EmpNo = "12x"
97
+ e = validate(t, cmd)
98
+ if e.AuditSubmittedEmpNo != "12x" || e.AuditDetail != nil {
99
+ t.Errorf("malformed: submitted %q detail %v", e.AuditSubmittedEmpNo, e.AuditDetail)
100
+ }
101
+ }
102
+
103
+ type spyPolicy struct{ empNo string }
104
+
105
+ func (s *spyPolicy) Check(_, empNo string) []web.FieldError {
106
+ s.empNo = empNo
107
+ return []web.FieldError{{Field: "password", Code: web.CodePasswordPolicy}}
108
+ }
109
+
110
+ func TestPolicyRunsWithFormatErrorsAndSeesEmpNoOnlyWhenWellFormed(t *testing.T) {
111
+ spy := &spyPolicy{}
112
+ svc := &account.Service{Policy: spy}
113
+ cmd := good()
114
+ cmd.Email = "bad"
115
+
116
+ _, err := svc.Register(context.Background(), cmd, account.Request{})
117
+ var e *web.Error
118
+ if !errors.As(err, &e) {
119
+ t.Fatal(err)
120
+ }
121
+ if got := strings.Join(codes(e), " "); got != "email:INVALID_FORMAT password:PASSWORD_POLICY" {
122
+ t.Errorf("fields = %q: format and policy errors must come together", got)
123
+ }
124
+ if spy.empNo != "089290" {
125
+ t.Errorf("policy got empNo %q", spy.empNo)
126
+ }
127
+
128
+ cmd.EmpNo = "bad"
129
+ _, _ = svc.Register(context.Background(), cmd, account.Request{})
130
+ if spy.empNo != "" {
131
+ t.Errorf("policy got empNo %q for a malformed ID; CONTAINS_EMP_NO must be skipped", spy.empNo)
132
+ }
133
+ }
134
+
135
+ func TestTopCodeIsPasswordPolicyWhenFirst(t *testing.T) {
136
+ svc := &account.Service{Policy: &spyPolicy{}}
137
+ _, err := svc.Register(context.Background(), good(), account.Request{})
138
+ var e *web.Error
139
+ if !errors.As(err, &e) || e.Code != web.CodePasswordPolicy || e.Status != 422 {
140
+ t.Fatalf("%v", err)
141
+ }
142
+ }
143
+
144
+ func handlerFor(got *error) http.Handler {
145
+ h := account.Handler{
146
+ Service: &account.Service{Policy: account.StubPolicy{}},
147
+ Fail: func(_ http.ResponseWriter, _ *http.Request, err error) { *got = err },
148
+ }
149
+ return http.HandlerFunc(h.Register)
150
+ }
151
+
152
+ func post(h http.Handler, contentType, body string) {
153
+ req := httptest.NewRequest("POST", "/api/v1/auth/register", strings.NewReader(body))
154
+ if contentType != "" {
155
+ req.Header.Set("Content-Type", contentType)
156
+ }
157
+ h.ServeHTTP(httptest.NewRecorder(), req)
158
+ }
159
+
160
+ func TestHandlerTransportErrors(t *testing.T) {
161
+ cases := []struct {
162
+ name, ct, body string
163
+ code web.ErrorCode
164
+ audited bool
165
+ }{
166
+ {"broken JSON", "application/json", `{`, web.CodeMalformedRequest, true},
167
+ {"missing field", "application/json", `{"empNo":"089290","name":"n","email":"a@b"}`, web.CodeMalformedRequest, true},
168
+ {"null field", "application/json", `{"empNo":"089290","name":"n","email":"a@b","password":null}`, web.CodeMalformedRequest, true},
169
+ {"wrong type", "application/json", `{"empNo":89290,"name":"n","email":"a@b","password":"x"}`, web.CodeMalformedRequest, true},
170
+ {"duplicate key", "application/json", `{"empNo":"1","empNo":"2","name":"n","email":"a@b","password":"x"}`, web.CodeMalformedRequest, true},
171
+ {"not JSON", "text/plain", `{}`, web.CodeUnsupportedMedia, false},
172
+ {"no content type", "", `{}`, web.CodeUnsupportedMedia, false},
173
+ {"over 16 KiB", "application/json", `{"name":"` + strings.Repeat("x", 17<<10) + `"}`, web.CodeRequestTooLarge, false},
174
+ }
175
+ for _, tc := range cases {
176
+ t.Run(tc.name, func(t *testing.T) {
177
+ var got error
178
+ post(handlerFor(&got), tc.ct, tc.body)
179
+ var e *web.Error
180
+ if !errors.As(got, &e) || e.Code != tc.code {
181
+ t.Fatalf("got %v, want %s", got, tc.code)
182
+ }
183
+ if audited := e.AuditReason != ""; audited != tc.audited {
184
+ t.Errorf("audited = %v, want %v (PRD 6.11: only 400, 409, 422)", audited, tc.audited)
185
+ }
186
+ if tc.audited && (e.AuditAction != "AUTH_REGISTER" || e.AuditOutcome != "FAILED" || e.AuditSubmittedEmpNo != "") {
187
+ t.Errorf("audit fields = %q %q %q", e.AuditAction, e.AuditOutcome, e.AuditSubmittedEmpNo)
188
+ }
189
+ })
190
+ }
191
+ }
@@ -0,0 +1,58 @@
1
+ package account
2
+
3
+ import (
4
+ "errors"
5
+ "net/http"
6
+
7
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/api"
8
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/shared/web"
9
+ )
10
+
11
+ // maxJSONBody is the JSON body limit (error-codes.md §2: 16 KiB → 413).
12
+ const maxJSONBody = 16 << 10
13
+
14
+ // Handler is the HTTP adapter of the account module. Fail is the central error
15
+ // adapter (server.ErrorAdapter.Write); the module cannot import server.
16
+ type Handler struct {
17
+ Service *Service
18
+ Fail func(w http.ResponseWriter, r *http.Request, err error)
19
+ }
20
+
21
+ // registerBody holds pointers so DecodeJSON can tell absent/null (400) from
22
+ // empty (422). Unknown keys are ignored.
23
+ type registerBody struct {
24
+ EmpNo *string `json:"empNo"`
25
+ Name *string `json:"name"`
26
+ Email *string `json:"email"`
27
+ Password *string `json:"password"`
28
+ }
29
+
30
+ // Register serves POST /auth/register: 201 {empNo, name, email}, no cookie, no Location.
31
+ func (h Handler) Register(w http.ResponseWriter, r *http.Request) {
32
+ r.Body = http.MaxBytesReader(w, r.Body, maxJSONBody)
33
+ var b registerBody
34
+ if err := web.DecodeJSON(r, &b); err != nil {
35
+ h.Fail(w, r, auditMalformed(err))
36
+ return
37
+ }
38
+ u, err := h.Service.Register(r.Context(), RegisterCommand{
39
+ EmpNo: *b.EmpNo, Name: *b.Name, Email: *b.Email, Password: *b.Password,
40
+ }, Request{
41
+ Method: r.Method, Route: web.RoutePattern(r), UserAgent: r.UserAgent(), IP: web.ClientIP(r),
42
+ })
43
+ if err != nil {
44
+ h.Fail(w, r, err)
45
+ return
46
+ }
47
+ web.WriteJSON(w, http.StatusCreated, api.RegisterResponse{EmpNo: u.EmpNo, Name: u.Name, Email: u.Email})
48
+ }
49
+
50
+ // auditMalformed marks a 400 as a FAILED registration (PRD §6.11 audits 400,
51
+ // 409 and 422 here). Other transport errors (413, 415) are not audited.
52
+ func auditMalformed(err error) error {
53
+ var e *web.Error
54
+ if errors.As(err, &e) && e.Code == web.CodeMalformedRequest {
55
+ return failed(e, "malformed_request", "", false)
56
+ }
57
+ return err
58
+ }
@@ -0,0 +1,268 @@
1
+ //go:build integration
2
+
3
+ package account_test
4
+
5
+ import (
6
+ "database/sql"
7
+ "encoding/json"
8
+ "log/slog"
9
+ "net/http"
10
+ "net/http/httptest"
11
+ "strings"
12
+ "sync"
13
+ "testing"
14
+
15
+ "golang.org/x/crypto/bcrypt"
16
+
17
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/account"
18
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/audit"
19
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/server"
20
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/shared/dbtest"
21
+ "github.com/brian-huanggg/AwardRegistration/backend/test/apispec"
22
+ )
23
+
24
+ type env struct {
25
+ db *sql.DB
26
+ h http.Handler
27
+ }
28
+
29
+ func newEnv(t *testing.T) env {
30
+ t.Helper()
31
+ db := dbtest.New(t)
32
+ auditSvc := audit.New(db, nil)
33
+ errs := server.ErrorAdapter{Audit: auditSvc}
34
+ api := server.API{Account: account.Handler{
35
+ Service: &account.Service{DB: db, Audit: auditSvc, Policy: account.StubPolicy{}},
36
+ Fail: errs.Write,
37
+ }}
38
+ h := apispec.Load(t).Validate(t, server.NewPublic(slog.New(slog.DiscardHandler), server.Mount(api)))
39
+ return env{db: db, h: h}
40
+ }
41
+
42
+ func (e env) register(body string) *httptest.ResponseRecorder {
43
+ req := httptest.NewRequest("POST", "/api/v1/auth/register", strings.NewReader(body))
44
+ req.Header.Set("Content-Type", "application/json")
45
+ req.Header.Set("User-Agent", "it/1")
46
+ req.RemoteAddr = "192.0.2.9:4000"
47
+ rec := httptest.NewRecorder()
48
+ e.h.ServeHTTP(rec, req)
49
+ return rec
50
+ }
51
+
52
+ func reg(empNo, name, email, pw string) string {
53
+ b, _ := json.Marshal(map[string]string{"empNo": empNo, "name": name, "email": email, "password": pw})
54
+ return string(b)
55
+ }
56
+
57
+ type auditRow struct {
58
+ Action, Outcome, ActorRole, ErrorCode string
59
+ Status sql.NullInt64
60
+ Route, Method, Submitted, After sql.NullString
61
+ TargetType sql.NullString
62
+ }
63
+
64
+ func (e env) auditRows(t *testing.T) []auditRow {
65
+ t.Helper()
66
+ rows, err := e.db.Query(`SELECT action, outcome, actor_role, COALESCE(error_code,''), status, route, method,
67
+ submitted_emp_no, CAST(after_json AS CHAR), target_type FROM audit_log ORDER BY id`)
68
+ if err != nil {
69
+ t.Fatal(err)
70
+ }
71
+ defer rows.Close()
72
+ var out []auditRow
73
+ for rows.Next() {
74
+ var r auditRow
75
+ if err := rows.Scan(&r.Action, &r.Outcome, &r.ActorRole, &r.ErrorCode, &r.Status, &r.Route, &r.Method,
76
+ &r.Submitted, &r.After, &r.TargetType); err != nil {
77
+ t.Fatal(err)
78
+ }
79
+ out = append(out, r)
80
+ }
81
+ return out
82
+ }
83
+
84
+ func (e env) users(t *testing.T) int {
85
+ t.Helper()
86
+ var n int
87
+ if err := e.db.QueryRow(`SELECT COUNT(*) FROM app_user`).Scan(&n); err != nil {
88
+ t.Fatal(err)
89
+ }
90
+ return n
91
+ }
92
+
93
+ func problemCode(t *testing.T, rec *httptest.ResponseRecorder) string {
94
+ t.Helper()
95
+ var p struct {
96
+ Code string
97
+ Errors []struct{ Field, Code string }
98
+ }
99
+ if err := json.Unmarshal(rec.Body.Bytes(), &p); err != nil {
100
+ t.Fatal(err)
101
+ }
102
+ return p.Code
103
+ }
104
+
105
+ func TestRegisterSuccess(t *testing.T) {
106
+ e := newEnv(t)
107
+ rec := e.register(reg("089290", " 王 小明 ", " Wang.Xiao@Example.COM ", "Correct-Horse-1"))
108
+
109
+ if rec.Code != 201 {
110
+ t.Fatalf("status %d: %s", rec.Code, rec.Body)
111
+ }
112
+ var body map[string]string
113
+ _ = json.Unmarshal(rec.Body.Bytes(), &body)
114
+ if len(body) != 3 || body["empNo"] != "089290" || body["name"] != "王 小明" || body["email"] != "wang.xiao@example.com" {
115
+ t.Errorf("body = %v: want the cleaned values, no password, no id", body)
116
+ }
117
+ if rec.Header().Get("Set-Cookie") != "" || rec.Header().Get("Location") != "" {
118
+ t.Errorf("headers = %v: registration sets no cookie and no Location", rec.Header())
119
+ }
120
+
121
+ var name, email, hash, role string
122
+ if err := e.db.QueryRow(`SELECT name, email, password_hash, role FROM app_user WHERE emp_no='089290'`).
123
+ Scan(&name, &email, &hash, &role); err != nil {
124
+ t.Fatal(err)
125
+ }
126
+ if name != "王 小明" || email != "wang.xiao@example.com" || role != "USER" {
127
+ t.Errorf("stored %q %q %q", name, email, role)
128
+ }
129
+ if cost, err := bcrypt.Cost([]byte(hash)); err != nil || cost != 10 {
130
+ t.Errorf("hash cost %d err %v", cost, err)
131
+ }
132
+ if bcrypt.CompareHashAndPassword([]byte(hash), []byte("Correct-Horse-1")) != nil {
133
+ t.Error("hash does not match the password")
134
+ }
135
+
136
+ rows := e.auditRows(t)
137
+ if len(rows) != 1 {
138
+ t.Fatalf("audit rows = %+v", rows)
139
+ }
140
+ r := rows[0]
141
+ if r.Action != "AUTH_REGISTER" || r.Outcome != "SUCCESS" || r.ActorRole != "ANONYMOUS" || r.Status.Int64 != 201 ||
142
+ r.Route.String != "/api/v1/auth/register" || r.TargetType.String != "USER" {
143
+ t.Errorf("row = %+v", r)
144
+ }
145
+ if strings.Contains(r.After.String, "@") || strings.Contains(r.After.String, "Correct") ||
146
+ !strings.Contains(r.After.String, "089290") || !strings.Contains(r.After.String, "王 小明") {
147
+ t.Errorf("after_json = %s: want empNo and name, never email or password", r.After.String)
148
+ }
149
+ }
150
+
151
+ func TestRegisterConflicts(t *testing.T) {
152
+ e := newEnv(t)
153
+ if rec := e.register(reg("089290", "A", "a@x.com", "pw")); rec.Code != 201 {
154
+ t.Fatal(rec.Body)
155
+ }
156
+
157
+ cases := []struct {
158
+ name, body, code, reason string
159
+ }{
160
+ {"same employee ID", reg("089290", "B", "b@x.com", "pw"), "EMP_NO_TAKEN", "emp_no_taken"},
161
+ {"same email, any case", reg("089291", "B", " A@X.COM ", "pw"), "EMAIL_TAKEN", "email_taken"},
162
+ {"both taken reports the employee ID", reg("089290", "B", "a@x.com", "pw"), "EMP_NO_TAKEN", "emp_no_taken"},
163
+ }
164
+ for _, tc := range cases {
165
+ rec := e.register(tc.body)
166
+ if rec.Code != 409 || problemCode(t, rec) != tc.code {
167
+ t.Errorf("%s: %d %s", tc.name, rec.Code, rec.Body)
168
+ }
169
+ }
170
+ if n := e.users(t); n != 1 {
171
+ t.Errorf("users = %d", n)
172
+ }
173
+
174
+ rows := e.auditRows(t)
175
+ if len(rows) != 4 {
176
+ t.Fatalf("audit rows = %+v", rows)
177
+ }
178
+ for i, tc := range cases {
179
+ r := rows[i+1]
180
+ if r.Action != "AUTH_REGISTER" || r.Outcome != "FAILED" || r.ErrorCode != tc.code || r.Status.Int64 != 409 ||
181
+ !strings.Contains(r.After.String, `"reason":"`+tc.reason+`"`) {
182
+ t.Errorf("%s: row = %+v", tc.name, r)
183
+ }
184
+ }
185
+ if rows[1].Submitted.String != "089290" {
186
+ t.Errorf("submitted = %q", rows[1].Submitted.String)
187
+ }
188
+ }
189
+
190
+ func TestRegisterValidationBeatsConflictAndIsAudited(t *testing.T) {
191
+ e := newEnv(t)
192
+ e.register(reg("089290", "A", "a@x.com", "pw"))
193
+
194
+ // The employee ID is taken, but the email is malformed: 422, never 409.
195
+ rec := e.register(reg("089290", "B", "a b@x.com", "pw"))
196
+ if rec.Code != 422 || problemCode(t, rec) != "VALIDATION_FAILED" {
197
+ t.Fatalf("%d %s", rec.Code, rec.Body)
198
+ }
199
+ rec = e.register(reg("12 4", "B/C", "ok@x.com", "pw"))
200
+ if rec.Code != 422 {
201
+ t.Fatalf("%d %s", rec.Code, rec.Body)
202
+ }
203
+
204
+ rows := e.auditRows(t)
205
+ if len(rows) != 3 {
206
+ t.Fatalf("rows = %+v", rows)
207
+ }
208
+ if r := rows[1]; r.Outcome != "FAILED" || r.Status.Int64 != 422 || r.Submitted.String != "089290" ||
209
+ !strings.Contains(r.After.String, `"empNo":"089290"`) || !strings.Contains(r.After.String, `"reason":"validation"`) {
210
+ t.Errorf("422 row = %+v", r)
211
+ }
212
+ // A malformed employee ID is kept in the submitted column only, not in after_json.
213
+ if r := rows[2]; r.Submitted.String != "12 4" || strings.Contains(r.After.String, "empNo") {
214
+ t.Errorf("malformed-ID row = %+v", r)
215
+ }
216
+ if e.users(t) != 1 {
217
+ t.Error("a rejected registration created a user")
218
+ }
219
+ }
220
+
221
+ func TestRegisterMalformedIsAuditedButUnsupportedMediaIsNot(t *testing.T) {
222
+ e := newEnv(t)
223
+
224
+ rec := e.register(`{"empNo":"089290"`)
225
+ if rec.Code != 400 || problemCode(t, rec) != "MALFORMED_REQUEST" {
226
+ t.Fatalf("%d %s", rec.Code, rec.Body)
227
+ }
228
+ req := httptest.NewRequest("POST", "/api/v1/auth/register", strings.NewReader("x"))
229
+ req.Header.Set("Content-Type", "text/plain")
230
+ rec = httptest.NewRecorder()
231
+ e.h.ServeHTTP(rec, req)
232
+ if rec.Code != 415 {
233
+ t.Fatalf("%d %s", rec.Code, rec.Body)
234
+ }
235
+
236
+ rows := e.auditRows(t)
237
+ if len(rows) != 1 || rows[0].Outcome != "FAILED" || rows[0].Status.Int64 != 400 || rows[0].ActorRole != "ANONYMOUS" {
238
+ t.Fatalf("rows = %+v: only the 400 is audited", rows)
239
+ }
240
+ }
241
+
242
+ func TestRegisterRaceHasOneWinner(t *testing.T) {
243
+ e := newEnv(t)
244
+ const n = 6
245
+ codes := make([]int, n)
246
+ var wg sync.WaitGroup
247
+ for i := range n {
248
+ wg.Add(1)
249
+ go func() {
250
+ defer wg.Done()
251
+ codes[i] = e.register(reg("089290", "A", "race"+string(rune('a'+i))+"@x.com", "pw")).Code
252
+ }()
253
+ }
254
+ wg.Wait()
255
+
256
+ created, conflicts := 0, 0
257
+ for _, c := range codes {
258
+ switch c {
259
+ case 201:
260
+ created++
261
+ case 409:
262
+ conflicts++
263
+ }
264
+ }
265
+ if created != 1 || conflicts != n-1 || e.users(t) != 1 {
266
+ t.Fatalf("codes = %v, users = %d", codes, e.users(t))
267
+ }
268
+ }
@@ -0,0 +1,35 @@
1
+ // Package store holds the SQL of the account module. Only the account module
2
+ // may import it (Go's internal rule); no other module touches app_user.
3
+ package store
4
+
5
+ import (
6
+ "context"
7
+ "database/sql"
8
+ "fmt"
9
+
10
+ "github.com/google/uuid"
11
+ )
12
+
13
+ // NewUser is a row to insert. Every value is already validated and cleaned;
14
+ // the table's CHECKs are the second line of defence (V003).
15
+ type NewUser struct {
16
+ ID uuid.UUID
17
+ EmpNo string
18
+ Name string
19
+ Email string // lower-cased
20
+ PasswordHash string // bcrypt, cost 10
21
+ }
22
+
23
+ // InsertUser inserts a USER in the caller's transaction. A unique-key
24
+ // violation (emp_no or email) is returned as the driver error; the service
25
+ // maps it with db.DuplicateKey.
26
+ func InsertUser(ctx context.Context, tx *sql.Tx, u NewUser) error {
27
+ _, err := tx.ExecContext(ctx,
28
+ `INSERT INTO app_user (id, emp_no, name, email, password_hash, role)
29
+ VALUES (?, ?, ?, ?, ?, 'USER')`,
30
+ u.ID.String(), u.EmpNo, u.Name, u.Email, u.PasswordHash)
31
+ if err != nil {
32
+ return fmt.Errorf("insert app_user: %w", err)
33
+ }
34
+ return nil
35
+ }