@johnnyboy11234/esg-idea-contest 0.2.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.
- package/backend/api/openapi.yaml +44 -0
- package/backend/cmd/server/main.go +11 -1
- package/backend/db/migration/V004__session.sql +19 -0
- package/backend/go.mod +1 -1
- package/backend/internal/account/account_test.go +191 -0
- package/backend/internal/account/handler.go +58 -0
- package/backend/internal/account/integration_test.go +268 -0
- package/backend/internal/account/internal/store/store.go +35 -0
- package/backend/internal/account/service.go +153 -0
- package/backend/internal/account/validate.go +100 -0
- package/backend/internal/api/api.gen.go +82 -0
- package/backend/internal/audit/integration_test.go +1 -1
- package/backend/internal/auth/internal/store/session.go +111 -0
- package/backend/internal/auth/session.go +113 -0
- package/backend/internal/auth/session_integration_test.go +245 -0
- package/backend/internal/auth/session_test.go +36 -0
- package/backend/internal/server/api.go +7 -0
- package/backend/internal/server/errors.go +10 -25
- package/backend/internal/server/errors_test.go +18 -0
- package/backend/internal/shared/db/intx_test.go +25 -0
- package/backend/internal/shared/db/mysqlerr.go +26 -0
- package/backend/internal/shared/security/security.go +6 -2
- package/backend/internal/shared/security/security_test.go +6 -4
- package/backend/internal/shared/web/problem.go +6 -0
- package/backend/internal/shared/web/request.go +27 -0
- package/frontend/src/shared/text/index.ts +93 -0
- package/frontend/src/shared/text/text.test.ts +22 -0
- package/package.json +1 -1
- package/frontend/src/shared/text/.gitkeep +0 -0
package/backend/api/openapi.yaml
CHANGED
|
@@ -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
|
-
|
|
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,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
|
+
}
|