@johnnyboy11234/esg-idea-contest 0.1.0 → 0.2.1

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 (40) hide show
  1. package/backend/api/openapi.yaml +44 -0
  2. package/backend/cmd/server/main.go +11 -1
  3. package/backend/db/migration/V002__audit_log_full.sql +32 -0
  4. package/backend/db/migration/V003__app_user.sql +36 -0
  5. package/backend/db/migration/V004__session.sql +19 -0
  6. package/backend/go.mod +1 -1
  7. package/backend/internal/account/account_test.go +191 -0
  8. package/backend/internal/account/handler.go +58 -0
  9. package/backend/internal/account/integration_test.go +268 -0
  10. package/backend/internal/account/internal/store/store.go +35 -0
  11. package/backend/internal/account/service.go +153 -0
  12. package/backend/internal/account/validate.go +100 -0
  13. package/backend/internal/api/api.gen.go +82 -0
  14. package/backend/internal/audit/event.go +119 -0
  15. package/backend/internal/audit/integration_test.go +270 -0
  16. package/backend/internal/audit/sanitize.go +102 -0
  17. package/backend/internal/audit/sanitize_test.go +134 -0
  18. package/backend/internal/audit/service.go +157 -0
  19. package/backend/internal/auth/internal/store/session.go +111 -0
  20. package/backend/internal/auth/session.go +113 -0
  21. package/backend/internal/auth/session_integration_test.go +245 -0
  22. package/backend/internal/auth/session_test.go +36 -0
  23. package/backend/internal/server/api.go +7 -0
  24. package/backend/internal/server/errors.go +80 -0
  25. package/backend/internal/server/errors_test.go +116 -0
  26. package/backend/internal/shared/db/intx_test.go +25 -0
  27. package/backend/internal/shared/db/mysqlerr.go +26 -0
  28. package/backend/internal/shared/security/security.go +102 -0
  29. package/backend/internal/shared/security/security_test.go +62 -0
  30. package/backend/internal/shared/web/problem.go +29 -7
  31. package/backend/internal/shared/web/request.go +27 -0
  32. package/backend/test/migration/app_user_test.go +112 -0
  33. package/docs/Adr/0001-Backend-in-go.md +3 -3
  34. package/docs/Open-items.md +8 -3
  35. package/docs/PR-plan.md +6 -4
  36. package/docs/PRD.md +2 -1
  37. package/frontend/src/shared/text/index.ts +93 -0
  38. package/frontend/src/shared/text/text.test.ts +22 -0
  39. package/package.json +1 -1
  40. 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,8 @@ 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"
16
18
  "github.com/brian-huanggg/AwardRegistration/backend/internal/deadline"
17
19
  "github.com/brian-huanggg/AwardRegistration/backend/internal/server"
18
20
  "github.com/brian-huanggg/AwardRegistration/backend/internal/shared/config"
@@ -42,7 +44,15 @@ func run() error {
42
44
  }
43
45
  defer func() { _ = db.Close() }()
44
46
 
45
- si := server.API{Deadline: deadline.Handler{Store: deadline.SQLStore{DB: db}}}
47
+ auditSvc := audit.New(db, log)
48
+ errs := server.ErrorAdapter{Audit: auditSvc, Log: log}
49
+ si := server.API{
50
+ Account: account.Handler{
51
+ Service: &account.Service{DB: db, Audit: auditSvc, Policy: account.StubPolicy{}},
52
+ Fail: errs.Write,
53
+ },
54
+ Deadline: deadline.Handler{Store: deadline.SQLStore{DB: db}},
55
+ }
46
56
 
47
57
  var ready atomic.Bool
48
58
  return server.Run(ctx, cfg, log, server.NewPublic(log, server.Mount(si)), &ready, server.DBCheck(db))
@@ -0,0 +1,32 @@
1
+ -- V002 (B1-03): the remaining audit_log columns (PRD 6.11 "Fields"). Expand-only: V001 created
2
+ -- the skeleton (id, occurred_at); the table is still empty, so NOT NULL columns are safe.
3
+ -- Append-only: application code only INSERTs (test/arch scans for anything else).
4
+ -- "before" / "after" are reserved words in MariaDB, hence before_json / after_json.
5
+ -- No foreign keys: a row must outlive the user or Idea it describes.
6
+
7
+ ALTER TABLE audit_log
8
+ ADD COLUMN request_id UUID NULL COMMENT 'X-Request-Id of the request' AFTER occurred_at,
9
+ ADD COLUMN actor_emp_no VARCHAR(64) NULL COMMENT 'NULL for ANONYMOUS and SYSTEM' AFTER request_id,
10
+ ADD COLUMN actor_role VARCHAR(16) NOT NULL AFTER actor_emp_no,
11
+ ADD COLUMN action VARCHAR(40) NOT NULL AFTER actor_role,
12
+ ADD COLUMN outcome VARCHAR(16) NOT NULL AFTER action,
13
+ ADD COLUMN target_type VARCHAR(16) NULL AFTER outcome,
14
+ ADD COLUMN target_id UUID NULL AFTER target_type,
15
+ ADD COLUMN method VARCHAR(8) NULL AFTER target_id,
16
+ ADD COLUMN route VARCHAR(200) NULL COMMENT 'route template, never the raw path' AFTER method,
17
+ ADD COLUMN status SMALLINT UNSIGNED NULL AFTER route,
18
+ ADD COLUMN error_code VARCHAR(64) NULL AFTER status,
19
+ ADD COLUMN ip INET6 NULL AFTER error_code,
20
+ ADD COLUMN user_agent VARCHAR(256) NULL AFTER ip,
21
+ ADD COLUMN submitted_emp_no VARCHAR(64) NULL COMMENT 'sanitized employee ID typed into a failed login or registration' AFTER user_agent,
22
+ ADD COLUMN before_json JSON NULL AFTER submitted_emp_no,
23
+ ADD COLUMN after_json JSON NULL AFTER before_json,
24
+ -- Reserved for the optional hash chain (PRD 9 #5); nothing writes them yet.
25
+ ADD COLUMN prev_hash BINARY(32) NULL,
26
+ ADD COLUMN hash BINARY(32) NULL,
27
+ ADD CONSTRAINT ck_audit_log_actor_role CHECK (actor_role IN ('USER', 'ADMIN', 'ANONYMOUS', 'SYSTEM')),
28
+ ADD CONSTRAINT ck_audit_log_outcome CHECK (outcome IN ('SUCCESS', 'FAILED', 'DENIED', 'THROTTLED')),
29
+ ADD CONSTRAINT ck_audit_log_target_type CHECK (target_type IS NULL OR target_type IN ('USER', 'IDEA', 'DEADLINE')),
30
+ ADD KEY ix_audit_log_actor (actor_emp_no, occurred_at, id),
31
+ ADD KEY ix_audit_log_request (request_id),
32
+ ADD KEY ix_audit_log_target (target_id, occurred_at, id);
@@ -0,0 +1,36 @@
1
+ -- V003 (B1-05): app_user, the one table for Users and Admins (PRD 4, 6.3).
2
+ -- Field rules are enforced here as well as in Go, so no code path can store a value the API would reject.
3
+ -- emp_no exactly 6 ASCII digits (string; keeps leading zeros)
4
+ -- name 1-50 code points, no control characters, none of / \ : * ? " < > | (PRD 6.2 #5, #8);
5
+ -- the NFC / whitespace clean-up itself is done by shared/text before the INSERT
6
+ -- email printable ASCII only, no whitespace, one '@' with non-empty sides, <= 254, already lower-cased (OI-14);
7
+ -- unique byte-exact (utf8mb4_bin). NULL only for Admins, who have no email (PRD 4).
8
+ -- password_hash bcrypt, cost 10 only (PRD 6.4, OI-13)
9
+ -- Enumerations are VARCHAR + CHECK, never ENUM. Times are database time in UTC.
10
+
11
+ CREATE TABLE app_user (
12
+ id UUID NOT NULL DEFAULT UUID(),
13
+ emp_no CHAR(6) NOT NULL,
14
+ name VARCHAR(50) NOT NULL,
15
+ email VARCHAR(254) NULL,
16
+ password_hash CHAR(60) NOT NULL,
17
+ role VARCHAR(8) NOT NULL,
18
+ -- Every authenticated request checks this (PRD 4); an Admin removed from Vault is disabled, not deleted.
19
+ enabled BOOLEAN NOT NULL DEFAULT TRUE,
20
+ created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
21
+ updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
22
+ PRIMARY KEY (id),
23
+ UNIQUE KEY uq_app_user_emp_no (emp_no),
24
+ -- Several NULLs are allowed (Admins); non-NULL values are unique.
25
+ UNIQUE KEY uq_app_user_email (email),
26
+ CONSTRAINT ck_app_user_emp_no CHECK (CHAR_LENGTH(emp_no) = 6 AND emp_no NOT REGEXP '[^0-9]'),
27
+ CONSTRAINT ck_app_user_name_length CHECK (CHAR_LENGTH(name) BETWEEN 1 AND 50),
28
+ CONSTRAINT ck_app_user_name_chars CHECK (name NOT REGEXP '[[:cntrl:]]' AND name NOT REGEXP '[/\\\\:*?"<>|]'),
29
+ CONSTRAINT ck_app_user_email CHECK (email IS NULL OR (
30
+ email NOT REGEXP '[^!-~]'
31
+ AND email = LOWER(email)
32
+ AND email REGEXP '^[^@]+@[^@]+$')),
33
+ CONSTRAINT ck_app_user_hash CHECK (password_hash REGEXP '^[$]2[aby][$]10[$][./A-Za-z0-9]{53}$'),
34
+ CONSTRAINT ck_app_user_role CHECK (role IN ('USER', 'ADMIN')),
35
+ CONSTRAINT ck_app_user_email_by_role CHECK ((role = 'USER' AND email IS NOT NULL) OR (role = 'ADMIN' AND email IS NULL))
36
+ ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_bin;
@@ -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
+ }