@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
@@ -0,0 +1,80 @@
1
+ package server
2
+
3
+ import (
4
+ "context"
5
+ "encoding/json"
6
+ "errors"
7
+ "log/slog"
8
+ "maps"
9
+ "net/http"
10
+
11
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/audit"
12
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/shared/web"
13
+ )
14
+
15
+ // Rejecter writes track-B audit rows. *audit.Service implements it.
16
+ type Rejecter interface {
17
+ RecordRejected(ctx context.Context, e audit.Event)
18
+ }
19
+
20
+ // ErrorAdapter is the one place that turns a handler's error into a response
21
+ // and, when the error asks for it, into a track-B audit row (Backend.md §7).
22
+ // No other code calls RecordRejected.
23
+ type ErrorAdapter struct {
24
+ Audit Rejecter
25
+ Log *slog.Logger // nil = discard
26
+ }
27
+
28
+ // Write answers err as problem+json. When err wraps a *web.Error with an
29
+ // AuditReason, the rejected attempt is recorded first (best effort: the row's
30
+ // failure never changes the response). Errors without a reason are not audited,
31
+ // and neither is anything that is not a *web.Error (it answers 500).
32
+ func (a ErrorAdapter) Write(w http.ResponseWriter, r *http.Request, err error) {
33
+ var e *web.Error
34
+ if !errors.As(err, &e) {
35
+ web.WriteError(w, r, err)
36
+ return
37
+ }
38
+ if e.AuditReason != "" {
39
+ a.record(r, e)
40
+ }
41
+ web.WriteProblem(w, r, e)
42
+ }
43
+
44
+ func (a ErrorAdapter) record(r *http.Request, e *web.Error) {
45
+ log := a.Log
46
+ if log == nil {
47
+ log = slog.New(slog.DiscardHandler)
48
+ }
49
+ if a.Audit == nil || e.AuditAction == "" {
50
+ // A programming error: the attempt cannot be recorded. Say so loudly,
51
+ // but still answer the client.
52
+ log.ErrorContext(r.Context(), "audit: rejected attempt without action or recorder",
53
+ "code", string(e.Code), "reason", e.AuditReason)
54
+ return
55
+ }
56
+ outcome := audit.Outcome(e.AuditOutcome)
57
+ if outcome == "" {
58
+ outcome = audit.Denied
59
+ }
60
+ detail := map[string]string{}
61
+ maps.Copy(detail, e.AuditDetail)
62
+ detail["reason"] = e.AuditReason // the reason always wins over a detail key
63
+ after, _ := json.Marshal(detail) // map of strings cannot fail
64
+ ev := audit.Event{
65
+ Action: audit.Action(e.AuditAction),
66
+ Outcome: outcome,
67
+ TargetType: audit.TargetType(e.AuditTargetType),
68
+ TargetID: e.AuditTargetID,
69
+ Method: r.Method,
70
+ Route: web.RoutePattern(r),
71
+ Status: e.Status,
72
+ ErrorCode: string(e.Code),
73
+ IP: web.ClientIP(r),
74
+ UserAgent: r.UserAgent(),
75
+
76
+ SubmittedEmpNo: e.AuditSubmittedEmpNo,
77
+ After: after,
78
+ }
79
+ a.Audit.RecordRejected(r.Context(), ev)
80
+ }
@@ -0,0 +1,116 @@
1
+ package server_test
2
+
3
+ import (
4
+ "context"
5
+ "errors"
6
+ "fmt"
7
+ "net/http"
8
+ "net/http/httptest"
9
+ "strings"
10
+ "testing"
11
+
12
+ "github.com/go-chi/chi/v5"
13
+ "github.com/google/uuid"
14
+
15
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/audit"
16
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/server"
17
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/shared/web"
18
+ )
19
+
20
+ type fakeRejecter struct{ got []audit.Event }
21
+
22
+ func (f *fakeRejecter) RecordRejected(_ context.Context, e audit.Event) { f.got = append(f.got, e) }
23
+
24
+ func serve(t *testing.T, ad server.ErrorAdapter, err error) *httptest.ResponseRecorder {
25
+ t.Helper()
26
+ r := chi.NewRouter()
27
+ r.Get("/ideas/{id}", func(w http.ResponseWriter, req *http.Request) { ad.Write(w, req, err) })
28
+ req := httptest.NewRequest("GET", "/ideas/123", nil)
29
+ req.RemoteAddr = "192.0.2.7:5555"
30
+ req.Header.Set("User-Agent", "ua/1")
31
+ rec := httptest.NewRecorder()
32
+ r.ServeHTTP(rec, req)
33
+ return rec
34
+ }
35
+
36
+ func TestAdapterWithReasonRecordsDenied(t *testing.T) {
37
+ id := uuid.New()
38
+ e := web.NewError(web.CodeNotFound, "resource not found")
39
+ e.AuditReason, e.AuditAction = "NOT_FOUND", "IDEA_VIEW"
40
+ e.AuditTargetType, e.AuditTargetID = "IDEA", id
41
+ f := &fakeRejecter{}
42
+
43
+ rec := serve(t, server.ErrorAdapter{Audit: f}, fmt.Errorf("wrapped: %w", e))
44
+
45
+ if rec.Code != 404 {
46
+ t.Fatalf("status = %d", rec.Code)
47
+ }
48
+ if len(f.got) != 1 {
49
+ t.Fatalf("rows = %d, want 1", len(f.got))
50
+ }
51
+ g := f.got[0]
52
+ if g.Outcome != audit.Denied || g.Action != audit.IdeaView || g.TargetType != audit.TargetIdea || g.TargetID != id ||
53
+ g.Method != "GET" || g.Route != "/ideas/{id}" || g.Status != 404 || g.ErrorCode != "NOT_FOUND" ||
54
+ g.IP.String() != "192.0.2.7" || g.UserAgent != "ua/1" || string(g.After) != `{"reason":"NOT_FOUND"}` {
55
+ t.Fatalf("event = %+v (after %s)", g, g.After)
56
+ }
57
+ if body := rec.Body.String(); contains(body, "reason") {
58
+ t.Fatalf("reason leaked to client: %s", body)
59
+ }
60
+ }
61
+
62
+ func TestAdapterOutcomeOverride(t *testing.T) {
63
+ e := web.NewError(web.CodeLoginThrottled, "")
64
+ e.AuditReason, e.AuditAction, e.AuditOutcome = "KEY_LOCKED", "AUTH_LOGIN", "THROTTLED"
65
+ f := &fakeRejecter{}
66
+ serve(t, server.ErrorAdapter{Audit: f}, e)
67
+ if len(f.got) != 1 || f.got[0].Outcome != audit.Throttled {
68
+ t.Fatalf("got %+v", f.got)
69
+ }
70
+ }
71
+
72
+ func TestAdapterWithoutReasonRecordsNothing(t *testing.T) {
73
+ f := &fakeRejecter{}
74
+ rec := serve(t, server.ErrorAdapter{Audit: f}, web.NewError(web.CodeEmailTaken, "x"))
75
+ if rec.Code != 409 || len(f.got) != 0 {
76
+ t.Fatalf("status %d rows %d", rec.Code, len(f.got))
77
+ }
78
+ }
79
+
80
+ func TestAdapterPlainErrorIs500AndUnaudited(t *testing.T) {
81
+ f := &fakeRejecter{}
82
+ rec := serve(t, server.ErrorAdapter{Audit: f}, errors.New("db password=hunter2"))
83
+ if rec.Code != 500 || len(f.got) != 0 || contains(rec.Body.String(), "hunter2") {
84
+ t.Fatalf("status %d rows %d body %s", rec.Code, len(f.got), rec.Body)
85
+ }
86
+ }
87
+
88
+ func TestAdapterReasonWithoutActionStillAnswers(t *testing.T) {
89
+ e := web.NewError(web.CodeForbidden, "no")
90
+ e.AuditReason = "ROLE"
91
+ f := &fakeRejecter{}
92
+ rec := serve(t, server.ErrorAdapter{Audit: f}, e)
93
+ if rec.Code != 403 || len(f.got) != 0 {
94
+ t.Fatalf("status %d rows %d", rec.Code, len(f.got))
95
+ }
96
+ }
97
+
98
+ func contains(s, sub string) bool { return strings.Contains(s, sub) }
99
+
100
+ func TestAdapterCarriesSubmittedEmpNoAndDetail(t *testing.T) {
101
+ e := web.NewError(web.CodeEmpNoTaken, "taken")
102
+ e.AuditReason, e.AuditAction, e.AuditOutcome = "emp_no_taken", "AUTH_REGISTER", "FAILED"
103
+ e.AuditSubmittedEmpNo = "089290"
104
+ e.AuditDetail = map[string]string{"empNo": "089290", "reason": "must not override"}
105
+ f := &fakeRejecter{}
106
+
107
+ rec := serve(t, server.ErrorAdapter{Audit: f}, e)
108
+
109
+ if rec.Code != 409 || len(f.got) != 1 {
110
+ t.Fatalf("status %d rows %d", rec.Code, len(f.got))
111
+ }
112
+ g := f.got[0]
113
+ if g.Outcome != audit.Failed || g.SubmittedEmpNo != "089290" || string(g.After) != `{"empNo":"089290","reason":"emp_no_taken"}` {
114
+ t.Fatalf("event = %+v (after %s)", g, g.After)
115
+ }
116
+ }
@@ -5,6 +5,7 @@ import (
5
5
  "database/sql"
6
6
  "database/sql/driver"
7
7
  "errors"
8
+ "fmt"
8
9
  "net/http"
9
10
  "testing"
10
11
  "time"
@@ -221,3 +222,27 @@ func TestSQLStateClassNonMySQL(t *testing.T) {
221
222
  t.Error("non-MySQL error must have no class and no contention")
222
223
  }
223
224
  }
225
+
226
+ func TestDuplicateKey(t *testing.T) {
227
+ dup := func(msg string) error { return fmt.Errorf("wrap: %w", &mysql.MySQLError{Number: 1062, Message: msg}) }
228
+ cases := []struct {
229
+ name string
230
+ err error
231
+ key string
232
+ ok bool
233
+ }{
234
+ {"plain", dup("Duplicate entry 'a@b' for key 'uq_app_user_email'"), "uq_app_user_email", true},
235
+ {"table qualified", dup("Duplicate entry '1' for key 'app_user.uq_app_user_emp_no'"), "uq_app_user_emp_no", true},
236
+ {"value contains the marker", dup("Duplicate entry 'for key ''x' for key 'uq_a'"), "uq_a", true},
237
+ {"no key in message", dup("boom"), "", true},
238
+ {"unterminated", dup("Duplicate entry 'x' for key 'uq_a"), "uq_a", true},
239
+ {"other number", &mysql.MySQLError{Number: 1205}, "", false},
240
+ {"not a mysql error", errors.New("x"), "", false},
241
+ }
242
+ for _, tc := range cases {
243
+ key, ok := DuplicateKey(tc.err)
244
+ if key != tc.key || ok != tc.ok {
245
+ t.Errorf("%s: got (%q, %v), want (%q, %v)", tc.name, key, ok, tc.key, tc.ok)
246
+ }
247
+ }
248
+ }
@@ -2,6 +2,7 @@ package db
2
2
 
3
3
  import (
4
4
  "errors"
5
+ "strings"
5
6
 
6
7
  "github.com/go-sql-driver/mysql"
7
8
  )
@@ -31,3 +32,28 @@ func sqlStateClass(err error) string {
31
32
  }
32
33
  return string(me.SQLState[:2])
33
34
  }
35
+
36
+ const errDupEntry = 1062 // ER_DUP_ENTRY
37
+
38
+ // DuplicateKey reports whether err is a unique-key violation (1062) and names
39
+ // the violated key, as in "Duplicate entry 'x' for key 'uq_app_user_email'".
40
+ func DuplicateKey(err error) (key string, ok bool) {
41
+ var me *mysql.MySQLError
42
+ if !errors.As(err, &me) || me.Number != errDupEntry {
43
+ return "", false
44
+ }
45
+ const marker = "for key '"
46
+ i := strings.LastIndex(me.Message, marker)
47
+ if i < 0 {
48
+ return "", true
49
+ }
50
+ rest := me.Message[i+len(marker):]
51
+ if j := strings.IndexByte(rest, '\''); j >= 0 {
52
+ rest = rest[:j]
53
+ }
54
+ // MariaDB may qualify the key with the table name ("app_user.uq_x").
55
+ if k := strings.LastIndexByte(rest, '.'); k >= 0 {
56
+ rest = rest[k+1:]
57
+ }
58
+ return rest, true
59
+ }
@@ -0,0 +1,102 @@
1
+ // Package security holds the authenticated caller's identity: the Principal
2
+ // built by the session middleware and the context accessors every later layer
3
+ // uses to read it (Backend.md §6). It has no dependencies inside the module so
4
+ // that any package may import it.
5
+ package security
6
+
7
+ import (
8
+ "context"
9
+
10
+ "github.com/google/uuid"
11
+ )
12
+
13
+ // Role is an account role (PRD §3). Leader, Member and Applicant are relations
14
+ // to one Idea, not roles.
15
+ type Role string
16
+
17
+ // The two account roles.
18
+ const (
19
+ RoleUser Role = "USER"
20
+ RoleAdmin Role = "ADMIN"
21
+ )
22
+
23
+ // Valid reports whether r is one of the two account roles.
24
+ func (r Role) Valid() bool { return r == RoleUser || r == RoleAdmin }
25
+
26
+ // ActorRole is the role recorded in the audit log (PRD §7): an account role, or
27
+ // ANONYMOUS (no session) or SYSTEM (no caller, e.g. background cleanup).
28
+ type ActorRole string
29
+
30
+ // The audit actor roles.
31
+ const (
32
+ ActorUser ActorRole = "USER"
33
+ ActorAdmin ActorRole = "ADMIN"
34
+ ActorAnonymous ActorRole = "ANONYMOUS"
35
+ ActorSystem ActorRole = "SYSTEM"
36
+ )
37
+
38
+ // Principal is the caller resolved from a valid session. The role is read from
39
+ // the account on every request, never copied into the session (PRD §6.5).
40
+ type Principal struct {
41
+ UserID uuid.UUID
42
+ EmpNo string
43
+ Role Role
44
+ }
45
+
46
+ // IsAdmin reports whether the principal has role ADMIN.
47
+ func (p Principal) IsAdmin() bool { return p.Role == RoleAdmin }
48
+
49
+ // ActorRole maps the account role to its audit value. An invalid role maps to
50
+ // ANONYMOUS so an unexpected value is never recorded as privileged.
51
+ func (p Principal) ActorRole() ActorRole {
52
+ switch p.Role {
53
+ case RoleUser:
54
+ return ActorUser
55
+ case RoleAdmin:
56
+ return ActorAdmin
57
+ }
58
+ return ActorAnonymous
59
+ }
60
+
61
+ type ctxKey int
62
+
63
+ const (
64
+ principalKey ctxKey = iota
65
+ systemKey
66
+ )
67
+
68
+ // WithPrincipal returns a context carrying p.
69
+ func WithPrincipal(ctx context.Context, p Principal) context.Context {
70
+ return context.WithValue(ctx, principalKey, p)
71
+ }
72
+
73
+ // PrincipalFrom returns the principal in ctx; ok is false when there is none.
74
+ func PrincipalFrom(ctx context.Context) (p Principal, ok bool) {
75
+ p, ok = ctx.Value(principalKey).(Principal)
76
+ return p, ok
77
+ }
78
+
79
+ // WithSystem marks ctx as a system action (no caller). A principal in ctx still
80
+ // wins, so a request can never be recorded as SYSTEM by accident.
81
+ func WithSystem(ctx context.Context) context.Context {
82
+ return context.WithValue(ctx, systemKey, true)
83
+ }
84
+
85
+ // ActorRoleFrom returns the audit actor role for ctx: the principal's role,
86
+ // else SYSTEM if marked with WithSystem, else ANONYMOUS.
87
+ func ActorRoleFrom(ctx context.Context) ActorRole {
88
+ if p, ok := PrincipalFrom(ctx); ok {
89
+ return p.ActorRole()
90
+ }
91
+ if sys, _ := ctx.Value(systemKey).(bool); sys {
92
+ return ActorSystem
93
+ }
94
+ return ActorAnonymous
95
+ }
96
+
97
+ // ActorEmpNo returns the caller's employee ID for the audit log, or "" when
98
+ // there is no principal.
99
+ func ActorEmpNo(ctx context.Context) string {
100
+ p, _ := PrincipalFrom(ctx)
101
+ return p.EmpNo
102
+ }
@@ -0,0 +1,62 @@
1
+ package security_test
2
+
3
+ import (
4
+ "context"
5
+ "testing"
6
+
7
+ "github.com/google/uuid"
8
+
9
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/shared/security"
10
+ )
11
+
12
+ func TestPrincipalRoundTrip(t *testing.T) {
13
+ ctx := context.Background()
14
+ if _, ok := security.PrincipalFrom(ctx); ok {
15
+ t.Fatal("empty context must have no principal")
16
+ }
17
+ want := security.Principal{UserID: uuid.New(), EmpNo: "A123", Role: security.RoleUser}
18
+ got, ok := security.PrincipalFrom(security.WithPrincipal(ctx, want))
19
+ if !ok || got != want {
20
+ t.Fatalf("got %+v, %v", got, ok)
21
+ }
22
+ }
23
+
24
+ func TestActorRole(t *testing.T) {
25
+ bg := context.Background()
26
+ user := security.Principal{UserID: uuid.New(), EmpNo: "U1", Role: security.RoleUser}
27
+ admin := security.Principal{UserID: uuid.New(), EmpNo: "A1", Role: security.RoleAdmin}
28
+ bad := security.Principal{UserID: uuid.New(), EmpNo: "X", Role: "ROOT"}
29
+
30
+ tests := []struct {
31
+ name string
32
+ ctx context.Context
33
+ want security.ActorRole
34
+ emp string
35
+ }{
36
+ {"anonymous", bg, security.ActorAnonymous, ""},
37
+ {"user", security.WithPrincipal(bg, user), security.ActorUser, "U1"},
38
+ {"admin", security.WithPrincipal(bg, admin), security.ActorAdmin, "A1"},
39
+ {"invalid role is never privileged", security.WithPrincipal(bg, bad), security.ActorAnonymous, "X"},
40
+ {"system", security.WithSystem(bg), security.ActorSystem, ""},
41
+ {"principal wins over system", security.WithSystem(security.WithPrincipal(bg, user)), security.ActorUser, "U1"},
42
+ }
43
+ for _, tc := range tests {
44
+ t.Run(tc.name, func(t *testing.T) {
45
+ if got := security.ActorRoleFrom(tc.ctx); got != tc.want {
46
+ t.Errorf("ActorRoleFrom = %q, want %q", got, tc.want)
47
+ }
48
+ if got := security.ActorEmpNo(tc.ctx); got != tc.emp {
49
+ t.Errorf("ActorEmpNo = %q, want %q", got, tc.emp)
50
+ }
51
+ })
52
+ }
53
+ }
54
+
55
+ func TestRole(t *testing.T) {
56
+ if !security.RoleUser.Valid() || !security.RoleAdmin.Valid() || security.Role("").Valid() || security.Role("ROOT").Valid() {
57
+ t.Error("Valid wrong")
58
+ }
59
+ if !(security.Principal{Role: security.RoleAdmin}).IsAdmin() || (security.Principal{Role: security.RoleUser}).IsAdmin() {
60
+ t.Error("IsAdmin wrong")
61
+ }
62
+ }
@@ -3,6 +3,8 @@ package web
3
3
  import (
4
4
  "encoding/json"
5
5
  "net/http"
6
+
7
+ "github.com/google/uuid"
6
8
  )
7
9
 
8
10
  // ProblemContentType is the RFC 9457 media type.
@@ -16,15 +18,35 @@ type FieldError struct {
16
18
  }
17
19
 
18
20
  // Error is the one error type handlers and services return. The central
19
- // adapter turns it into problem+json; AuditReason (set later by the audit PRs)
20
- // makes the adapter record a track-B row.
21
+ // adapter turns it into problem+json; a non-empty AuditReason makes the adapter
22
+ // record a track-B row (Backend.md §7). Without a reason nothing is audited.
23
+ //
24
+ // The Audit* fields are plain strings because package audit imports web.
21
25
  type Error struct {
22
- Status int
23
- Code ErrorCode
24
- Detail string
25
- Fields []FieldError
26
+ Status int
27
+ Code ErrorCode
28
+ Detail string
29
+ Fields []FieldError
30
+ Cause error
31
+
32
+ // AuditReason is the real reason of a rejected attempt (CSRF, role, state,
33
+ // not-yours, not-found). It is stored in the row's after_json, never sent
34
+ // to the client.
26
35
  AuditReason string
27
- Cause error
36
+ // AuditAction is the audit action of the attempted operation, e.g.
37
+ // "IDEA_VIEW". Required whenever AuditReason is set.
38
+ AuditAction string
39
+ // AuditOutcome is DENIED, FAILED or THROTTLED; empty means DENIED.
40
+ AuditOutcome string
41
+ // AuditTargetType and AuditTargetID name the resource, when there is one.
42
+ AuditTargetType string
43
+ AuditTargetID uuid.UUID
44
+ // AuditSubmittedEmpNo is the employee ID typed into a failed registration
45
+ // or login; the audit package sanitizes it before storing.
46
+ AuditSubmittedEmpNo string
47
+ // AuditDetail adds keys to the row's after_json beside "reason". Callers
48
+ // must not put secrets here.
49
+ AuditDetail map[string]string
28
50
  }
29
51
 
30
52
  func (e *Error) Error() string {
@@ -0,0 +1,27 @@
1
+ package web
2
+
3
+ import (
4
+ "net/http"
5
+ "net/netip"
6
+
7
+ "github.com/go-chi/chi/v5"
8
+ )
9
+
10
+ // RoutePattern is the matched chi pattern (/api/v1/ideas/{id}), never the raw
11
+ // path, so IDs do not leak into the audit log and rows group by route. It is
12
+ // empty when no route matched.
13
+ func RoutePattern(r *http.Request) string {
14
+ if rc := chi.RouteContext(r.Context()); rc != nil {
15
+ return rc.RoutePattern()
16
+ }
17
+ return ""
18
+ }
19
+
20
+ // ClientIP is the TCP peer. Trusted-proxy handling is not decided yet.
21
+ func ClientIP(r *http.Request) netip.Addr {
22
+ ap, err := netip.ParseAddrPort(r.RemoteAddr)
23
+ if err != nil {
24
+ return netip.Addr{}
25
+ }
26
+ return ap.Addr().Unmap()
27
+ }
@@ -0,0 +1,112 @@
1
+ //go:build integration
2
+
3
+ // Package migration holds schema-level tests against a real MariaDB with the real migrations.
4
+ package migration_test
5
+
6
+ import (
7
+ "database/sql"
8
+ "strings"
9
+ "testing"
10
+
11
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/shared/dbtest"
12
+ )
13
+
14
+ const hash = "$2a$10$" + "abcdefghijklmnopqrstuuABCDEFGHIJKLMNOPQRSTUVWXYZ01234" // 53 chars after the prefix
15
+
16
+ func insert(d *sql.DB, empNo, name string, email any, pw, role string) error {
17
+ _, err := d.Exec(`INSERT INTO app_user (emp_no, name, email, password_hash, role) VALUES (?,?,?,?,?)`,
18
+ empNo, name, email, pw, role)
19
+ return err
20
+ }
21
+
22
+ func TestAppUserFieldRules(t *testing.T) {
23
+ d := dbtest.New(t)
24
+ if len(hash) != 60 {
25
+ t.Fatalf("test hash has length %d", len(hash))
26
+ }
27
+
28
+ valid := []struct {
29
+ name, empNo, nm string
30
+ email any
31
+ role string
32
+ }{
33
+ {"user", "089290", "王小明", "a@b", "USER"},
34
+ {"user 50 code points", "000001", strings.Repeat("名", 50), "x@y.z", "USER"},
35
+ {"admin without email", "000002", "Admin", nil, "ADMIN"},
36
+ {"second admin (NULL emails do not collide)", "000003", "Admin 2", nil, "ADMIN"},
37
+ {"email max length", "000004", "N", strings.Repeat("a", 252) + "@b", "USER"},
38
+ }
39
+ for _, c := range valid {
40
+ if err := insert(d, c.empNo, c.nm, c.email, hash, c.role); err != nil {
41
+ t.Errorf("%s: want accepted, got %v", c.name, err)
42
+ }
43
+ }
44
+
45
+ invalid := []struct {
46
+ name, empNo, nm string
47
+ email any
48
+ pw, role string
49
+ }{
50
+ {"emp_no 5 digits", "12345", "N", "e1@x", hash, "USER"},
51
+ {"emp_no 7 digits", "1234567", "N", "e2@x", hash, "USER"},
52
+ {"emp_no letter", "12345a", "N", "e3@x", hash, "USER"},
53
+ {"emp_no newline", "12345\n", "N", "e4@x", hash, "USER"},
54
+ {"emp_no fullwidth digit", "123451", "N", "e5@x", hash, "USER"},
55
+ {"name empty", "100001", "", "e6@x", hash, "USER"},
56
+ {"name 51 code points", "100002", strings.Repeat("名", 51), "e7@x", hash, "USER"},
57
+ {"name control char", "100003", "a\tb", "e8@x", hash, "USER"},
58
+ {"name slash", "100004", "a/b", "e9@x", hash, "USER"},
59
+ {"name backslash", "100005", `a\b`, "e10@x", hash, "USER"},
60
+ {"name pipe", "100006", "a|b", "e11@x", hash, "USER"},
61
+ {"name quote", "100007", `a"b`, "e12@x", hash, "USER"},
62
+ {"email upper case", "100008", "N", "A@x", hash, "USER"},
63
+ {"email space", "100009", "N", "a b@x", hash, "USER"},
64
+ {"email non-ASCII", "100010", "N", "é@x", hash, "USER"},
65
+ {"email two @", "100011", "N", "a@b@c", hash, "USER"},
66
+ {"email no @", "100012", "N", "abc", hash, "USER"},
67
+ {"email empty local", "100013", "N", "@x", hash, "USER"},
68
+ {"email empty domain", "100014", "N", "a@", hash, "USER"},
69
+ {"email trailing newline", "100015", "N", "a@x\n", hash, "USER"},
70
+ {"email 255 chars", "100016", "N", strings.Repeat("a", 253) + "@b", hash, "USER"},
71
+ {"user without email", "100017", "N", nil, hash, "USER"},
72
+ {"admin with email", "100018", "N", "ad@x", hash, "ADMIN"},
73
+ {"role unknown", "100019", "N", "e13@x", hash, "OWNER"},
74
+ {"hash cost 12", "100020", "N", "e14@x", "$2a$12$" + hash[7:], "USER"},
75
+ {"hash plain text", "100021", "N", "e15@x", strings.Repeat("p", 60), "USER"},
76
+ {"duplicate emp_no", "089290", "N", "e16@x", hash, "USER"},
77
+ {"duplicate email", "100022", "N", "a@b", hash, "USER"},
78
+ }
79
+ for _, c := range invalid {
80
+ if err := insert(d, c.empNo, c.nm, c.email, c.pw, c.role); err == nil {
81
+ t.Errorf("%s: want rejected, got accepted", c.name)
82
+ }
83
+ }
84
+ }
85
+
86
+ func TestAppUserEmailUniquenessIsByteExact(t *testing.T) {
87
+ d := dbtest.New(t)
88
+ if err := insert(d, "200001", "N", "a@x", hash, "USER"); err != nil {
89
+ t.Fatal(err)
90
+ }
91
+ // Upper case is rejected by the CHECK, so the lower-cased form is the only one that can collide.
92
+ if err := insert(d, "200002", "N", "a@x", hash, "USER"); err == nil {
93
+ t.Fatal("duplicate lower-cased email accepted")
94
+ }
95
+ }
96
+
97
+ func TestAppUserDefaults(t *testing.T) {
98
+ d := dbtest.New(t)
99
+ if err := insert(d, "300001", "N", "d@x", hash, "USER"); err != nil {
100
+ t.Fatal(err)
101
+ }
102
+ var id string
103
+ var enabled bool
104
+ var created, updated sql.NullTime
105
+ if err := d.QueryRow(`SELECT id, enabled, created_at, updated_at FROM app_user WHERE emp_no='300001'`).
106
+ Scan(&id, &enabled, &created, &updated); err != nil {
107
+ t.Fatal(err)
108
+ }
109
+ if len(id) != 36 || !enabled || !created.Valid || !updated.Valid {
110
+ t.Fatalf("defaults wrong: id=%q enabled=%v created=%v updated=%v", id, enabled, created, updated)
111
+ }
112
+ }
@@ -56,9 +56,9 @@ This document checks what a Go implementation looks like if the current architec
56
56
 
57
57
  | # | Item |
58
58
  |---|---|
59
- | O1 | **Facts checked on Go 1.26.10.** `encoding/json` (v1) matches field names case-insensitively (`"EMPNO"` fills `empNo`) and, for a duplicate key, the last one wins. `encoding/json/v2` is **still behind `GOEXPERIMENT=jsonv2`** and cannot be imported without it, so it is not used. With the experiment on, v2 rejects duplicate keys and matches names case-sensitively, which is what the PRD wants. `Backend.md` §3 proposes the `Token()` pre-pass plus exact-name field picking. When v2 becomes stable, only the decoder in `shared/web` is replaced; the `test/binding` tests stay. Waiting for owner confirmation of the decoder design. |
60
- | O2 | Exclude generated code (sqlc, oapi-codegen) from the coverage denominator? Recommended: yes. |
61
- | O3 | Probe paths: keep the `/actuator/...` paths or switch to `/healthz`-style paths and update the chart. |
59
+ | O1 (= OI-53) | **Facts checked on Go 1.26.10.** `encoding/json` (v1) matches field names case-insensitively (`"EMPNO"` fills `empNo`) and, for a duplicate key, the last one wins. `encoding/json/v2` is **still behind `GOEXPERIMENT=jsonv2`** and cannot be imported without it, so it is not used. With the experiment on, v2 rejects duplicate keys and matches names case-sensitively, which is what the PRD wants. `Backend.md` §3 proposes the `Token()` pre-pass plus exact-name field picking. When v2 becomes stable, only the decoder in `shared/web` is replaced; the `test/binding` tests stay. Waiting for owner confirmation of the decoder design. |
60
+ | O2 (= OI-54) | Exclude generated code (sqlc, oapi-codegen) from the coverage denominator? Recommended: yes. |
61
+ | O3 (= OI-55) | Probe paths: keep the `/actuator/...` paths or switch to `/healthz`-style paths and update the chart. |
62
62
 
63
63
  ## What adoption requires
64
64