@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.
Files changed (29) hide show
  1. package/backend/api/openapi.yaml +44 -0
  2. package/backend/cmd/server/main.go +11 -1
  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/internal/store/session.go +111 -0
  14. package/backend/internal/auth/session.go +113 -0
  15. package/backend/internal/auth/session_integration_test.go +245 -0
  16. package/backend/internal/auth/session_test.go +36 -0
  17. package/backend/internal/server/api.go +7 -0
  18. package/backend/internal/server/errors.go +10 -25
  19. package/backend/internal/server/errors_test.go +18 -0
  20. package/backend/internal/shared/db/intx_test.go +25 -0
  21. package/backend/internal/shared/db/mysqlerr.go +26 -0
  22. package/backend/internal/shared/security/security.go +6 -2
  23. package/backend/internal/shared/security/security_test.go +6 -4
  24. package/backend/internal/shared/web/problem.go +6 -0
  25. package/backend/internal/shared/web/request.go +27 -0
  26. package/frontend/src/shared/text/index.ts +93 -0
  27. package/frontend/src/shared/text/text.test.ts +22 -0
  28. package/package.json +1 -1
  29. package/frontend/src/shared/text/.gitkeep +0 -0
@@ -0,0 +1,245 @@
1
+ //go:build integration
2
+
3
+ package auth_test
4
+
5
+ import (
6
+ "context"
7
+ "crypto/sha256"
8
+ "database/sql"
9
+ "encoding/base64"
10
+ "testing"
11
+ "time"
12
+
13
+ "github.com/google/uuid"
14
+
15
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/auth"
16
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/shared/dbtest"
17
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/shared/security"
18
+ )
19
+
20
+ const bcryptHash = "$2a$10$abcdefghijklmnopqrstuuABCDEFGHIJKLMNOPQRSTUVWXYZ01234" // shape only; satisfies ck_app_user_hash
21
+
22
+ func addUser(t *testing.T, db *sql.DB, empNo, role string, enabled bool) uuid.UUID {
23
+ t.Helper()
24
+ id := uuid.New()
25
+ var email any = empNo + "@example.com"
26
+ if role == "ADMIN" {
27
+ email = nil
28
+ }
29
+ if _, err := db.Exec(`INSERT INTO app_user (id, emp_no, name, email, password_hash, role, enabled) VALUES (?,?,?,?,?,?,?)`,
30
+ id.String(), empNo, "N "+empNo, email, bcryptHash, role, enabled); err != nil {
31
+ t.Fatal(err)
32
+ }
33
+ return id
34
+ }
35
+
36
+ // age moves a session's timestamps into the past (DB time is the only clock,
37
+ // so tests change the rows rather than the clock).
38
+ func age(t *testing.T, db *sql.DB, id string, created, lastAccess time.Duration) {
39
+ t.Helper()
40
+ h, _ := auth.Binding(id)
41
+ if _, err := db.Exec(`UPDATE session SET created_at = NOW(6) - INTERVAL ? SECOND, last_access_at = NOW(6) - INTERVAL ? SECOND WHERE id_hash = ?`,
42
+ int(created.Seconds()), int(lastAccess.Seconds()), h); err != nil {
43
+ t.Fatal(err)
44
+ }
45
+ }
46
+
47
+ func lastAccess(t *testing.T, db *sql.DB, id string) time.Time {
48
+ t.Helper()
49
+ h, _ := auth.Binding(id)
50
+ var ts time.Time
51
+ if err := db.QueryRow(`SELECT last_access_at FROM session WHERE id_hash = ?`, h).Scan(&ts); err != nil {
52
+ t.Fatal(err)
53
+ }
54
+ return ts
55
+ }
56
+
57
+ func TestSessions(t *testing.T) {
58
+ db := dbtest.New(t)
59
+ ctx := context.Background()
60
+ s := auth.New(db)
61
+ user := addUser(t, db, "100001", "USER", true)
62
+
63
+ create := func(t *testing.T, u uuid.UUID) string {
64
+ t.Helper()
65
+ id, err := s.Create(ctx, u)
66
+ if err != nil {
67
+ t.Fatal(err)
68
+ }
69
+ return id
70
+ }
71
+ valid := func(t *testing.T, id string) bool {
72
+ t.Helper()
73
+ _, ok, err := s.Validate(ctx, id)
74
+ if err != nil {
75
+ t.Fatal(err)
76
+ }
77
+ return ok
78
+ }
79
+
80
+ t.Run("create stores only the SHA-256 and returns a 32-byte ID", func(t *testing.T) {
81
+ id := create(t, user)
82
+ raw, err := base64.RawURLEncoding.DecodeString(id)
83
+ if err != nil || len(raw) != 32 {
84
+ t.Fatalf("id %q: %d bytes, %v", id, len(raw), err)
85
+ }
86
+ sum := sha256.Sum256(raw)
87
+ var n int
88
+ if err := db.QueryRow(`SELECT COUNT(*) FROM session WHERE id_hash = ?`, sum[:]).Scan(&n); err != nil || n != 1 {
89
+ t.Fatalf("hash row count = %d, %v", n, err)
90
+ }
91
+ if err := db.QueryRow(`SELECT COUNT(*) FROM session WHERE id_hash = ?`, raw).Scan(&n); err != nil || n != 0 {
92
+ t.Fatalf("raw ID stored: %d, %v", n, err)
93
+ }
94
+ })
95
+
96
+ t.Run("every login gets a new ID", func(t *testing.T) {
97
+ if a, b := create(t, user), create(t, user); a == b {
98
+ t.Fatal("same ID twice")
99
+ }
100
+ })
101
+
102
+ t.Run("validate returns the principal with the role from app_user", func(t *testing.T) {
103
+ admin := addUser(t, db, "200001", "ADMIN", true)
104
+ p, ok, err := s.Validate(ctx, create(t, admin))
105
+ if err != nil || !ok {
106
+ t.Fatalf("validate: %v %v", ok, err)
107
+ }
108
+ want := security.Principal{UserID: admin, EmpNo: "200001", Role: security.RoleAdmin}
109
+ if p != want {
110
+ t.Fatalf("principal = %+v, want %+v", p, want)
111
+ }
112
+ })
113
+
114
+ t.Run("unknown and malformed IDs are not valid", func(t *testing.T) {
115
+ unknown := base64.RawURLEncoding.EncodeToString(make([]byte, 32))
116
+ for _, id := range []string{unknown, "", "garbage"} {
117
+ if valid(t, id) {
118
+ t.Errorf("%q accepted", id)
119
+ }
120
+ }
121
+ })
122
+
123
+ t.Run("matched rows count: a valid session that needs no refresh still validates", func(t *testing.T) {
124
+ id := create(t, user)
125
+ before := lastAccess(t, db, id)
126
+ for i := 0; i < 2; i++ {
127
+ if !valid(t, id) {
128
+ t.Fatalf("validate #%d failed although the row matched", i+1)
129
+ }
130
+ }
131
+ if after := lastAccess(t, db, id); !after.Equal(before) {
132
+ t.Fatalf("last_access_at moved inside the 60 s window: %v -> %v", before, after)
133
+ }
134
+ })
135
+
136
+ t.Run("refresh happens only when last access is older than 60 s", func(t *testing.T) {
137
+ id := create(t, user)
138
+ age(t, db, id, 0, 59*time.Second)
139
+ before := lastAccess(t, db, id)
140
+ if !valid(t, id) || !lastAccess(t, db, id).Equal(before) {
141
+ t.Fatal("59 s old: must validate without writing")
142
+ }
143
+ age(t, db, id, 0, 61*time.Second)
144
+ before = lastAccess(t, db, id)
145
+ if !valid(t, id) || !lastAccess(t, db, id).After(before) {
146
+ t.Fatal("61 s old: must validate and refresh")
147
+ }
148
+ })
149
+
150
+ t.Run("idle timeout is 30 minutes", func(t *testing.T) {
151
+ id := create(t, user)
152
+ age(t, db, id, 0, 29*time.Minute+50*time.Second)
153
+ if !valid(t, id) {
154
+ t.Fatal("29m50s idle must still be valid")
155
+ }
156
+ age(t, db, id, 0, 30*time.Minute+5*time.Second)
157
+ if valid(t, id) {
158
+ t.Fatal("30m05s idle must be expired")
159
+ }
160
+ })
161
+
162
+ t.Run("absolute timeout is 8 hours, even for an active session", func(t *testing.T) {
163
+ id := create(t, user)
164
+ age(t, db, id, 7*time.Hour+59*time.Minute, 0)
165
+ if !valid(t, id) {
166
+ t.Fatal("7h59m old must still be valid")
167
+ }
168
+ age(t, db, id, 8*time.Hour+time.Minute, 0)
169
+ if valid(t, id) {
170
+ t.Fatal("8h01m old must be expired although just used")
171
+ }
172
+ })
173
+
174
+ t.Run("disabled user is rejected on the next request", func(t *testing.T) {
175
+ u := addUser(t, db, "100002", "USER", true)
176
+ id := create(t, u)
177
+ if !valid(t, id) {
178
+ t.Fatal("enabled user must validate")
179
+ }
180
+ if _, err := db.Exec(`UPDATE app_user SET enabled = FALSE WHERE id = ?`, u.String()); err != nil {
181
+ t.Fatal(err)
182
+ }
183
+ if valid(t, id) {
184
+ t.Fatal("disabled user must not validate")
185
+ }
186
+ })
187
+
188
+ t.Run("revoke is idempotent and ends only that session", func(t *testing.T) {
189
+ a, b := create(t, user), create(t, user)
190
+ for i := 0; i < 2; i++ {
191
+ if err := s.Revoke(ctx, a); err != nil {
192
+ t.Fatal(err)
193
+ }
194
+ }
195
+ if err := s.Revoke(ctx, "garbage"); err != nil {
196
+ t.Fatal(err)
197
+ }
198
+ if valid(t, a) || !valid(t, b) {
199
+ t.Fatal("revoke must end a and leave b")
200
+ }
201
+ })
202
+
203
+ t.Run("revoke all ends every session of the user only", func(t *testing.T) {
204
+ u, other := addUser(t, db, "100003", "USER", true), addUser(t, db, "100004", "USER", true)
205
+ a, b, o := create(t, u), create(t, u), create(t, other)
206
+ if err := s.RevokeAll(ctx, u); err != nil {
207
+ t.Fatal(err)
208
+ }
209
+ if valid(t, a) || valid(t, b) || !valid(t, o) {
210
+ t.Fatal("RevokeAll must end the user's sessions and no others")
211
+ }
212
+ })
213
+
214
+ t.Run("purge removes expired sessions only", func(t *testing.T) {
215
+ u := addUser(t, db, "100005", "USER", true)
216
+ live, idle, old := create(t, u), create(t, u), create(t, u)
217
+ age(t, db, idle, time.Hour, time.Hour)
218
+ age(t, db, old, 9*time.Hour, 0)
219
+ n, err := s.Purge(ctx)
220
+ if err != nil || n < 2 {
221
+ t.Fatalf("purged %d, %v; want at least 2", n, err)
222
+ }
223
+ if !valid(t, live) {
224
+ t.Fatal("live session purged")
225
+ }
226
+ for _, id := range []string{idle, old} {
227
+ h, _ := auth.Binding(id)
228
+ var c int
229
+ if err := db.QueryRow(`SELECT COUNT(*) FROM session WHERE id_hash = ?`, h).Scan(&c); err != nil || c != 0 {
230
+ t.Fatalf("expired row still present: %d, %v", c, err)
231
+ }
232
+ }
233
+ })
234
+
235
+ t.Run("deleting a user deletes the sessions", func(t *testing.T) {
236
+ u := addUser(t, db, "100006", "USER", true)
237
+ id := create(t, u)
238
+ if _, err := db.Exec(`DELETE FROM app_user WHERE id = ?`, u.String()); err != nil {
239
+ t.Fatal(err)
240
+ }
241
+ if valid(t, id) {
242
+ t.Fatal("session survived its user")
243
+ }
244
+ })
245
+ }
@@ -0,0 +1,36 @@
1
+ package auth_test
2
+
3
+ import (
4
+ "crypto/sha256"
5
+ "encoding/base64"
6
+ "testing"
7
+
8
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/auth"
9
+ )
10
+
11
+ func TestBindingIsSHA256OfDecodedID(t *testing.T) {
12
+ raw := make([]byte, 32)
13
+ for i := range raw {
14
+ raw[i] = byte(i)
15
+ }
16
+ want := sha256.Sum256(raw)
17
+ got, ok := auth.Binding(base64.RawURLEncoding.EncodeToString(raw))
18
+ if !ok || string(got) != string(want[:]) {
19
+ t.Fatalf("Binding = %x, %v; want %x", got, ok, want)
20
+ }
21
+ }
22
+
23
+ func TestBindingRejectsMalformedIDs(t *testing.T) {
24
+ for name, id := range map[string]string{
25
+ "empty": "",
26
+ "not base64": "!!!",
27
+ "too short": base64.RawURLEncoding.EncodeToString(make([]byte, 31)),
28
+ "too long": base64.RawURLEncoding.EncodeToString(make([]byte, 33)),
29
+ "padded": base64.URLEncoding.EncodeToString(make([]byte, 32)),
30
+ "std alpha": base64.StdEncoding.EncodeToString([]byte{0xfb, 0xff, 0xfe, 0xfb, 0xff, 0xfe, 0xfb, 0xff, 0xfe, 0xfb, 0xff, 0xfe, 0xfb, 0xff, 0xfe, 0xfb, 0xff, 0xfe, 0xfb, 0xff, 0xfe, 0xfb, 0xff, 0xfe, 0xfb, 0xff, 0xfe, 0xfb, 0xff, 0xfe, 0xfb, 0xff}),
31
+ } {
32
+ if _, ok := auth.Binding(id); ok {
33
+ t.Errorf("%s: %q accepted", name, id)
34
+ }
35
+ }
36
+ }
@@ -3,6 +3,7 @@ package server
3
3
  import (
4
4
  "net/http"
5
5
 
6
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/account"
6
7
  "github.com/brian-huanggg/AwardRegistration/backend/internal/api"
7
8
  "github.com/brian-huanggg/AwardRegistration/backend/internal/deadline"
8
9
  )
@@ -11,8 +12,14 @@ import (
11
12
  // module has not landed yet stay api.Unimplemented (501).
12
13
  type API struct {
13
14
  api.Unimplemented
15
+ Account account.Handler
14
16
  Deadline deadline.Handler
15
17
  }
16
18
 
17
19
  // GetDeadline serves GET /deadline.
18
20
  func (a API) GetDeadline(w http.ResponseWriter, r *http.Request) { a.Deadline.GetDeadline(w, r) }
21
+
22
+ // Register serves POST /auth/register.
23
+ func (a API) Register(w http.ResponseWriter, r *http.Request, _ api.RegisterParams) {
24
+ a.Account.Register(w, r)
25
+ }
@@ -5,10 +5,8 @@ import (
5
5
  "encoding/json"
6
6
  "errors"
7
7
  "log/slog"
8
+ "maps"
8
9
  "net/http"
9
- "net/netip"
10
-
11
- "github.com/go-chi/chi/v5"
12
10
 
13
11
  "github.com/brian-huanggg/AwardRegistration/backend/internal/audit"
14
12
  "github.com/brian-huanggg/AwardRegistration/backend/internal/shared/web"
@@ -59,37 +57,24 @@ func (a ErrorAdapter) record(r *http.Request, e *web.Error) {
59
57
  if outcome == "" {
60
58
  outcome = audit.Denied
61
59
  }
62
- after, _ := json.Marshal(map[string]string{"reason": e.AuditReason}) // map of strings cannot fail
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
63
64
  ev := audit.Event{
64
65
  Action: audit.Action(e.AuditAction),
65
66
  Outcome: outcome,
66
67
  TargetType: audit.TargetType(e.AuditTargetType),
67
68
  TargetID: e.AuditTargetID,
68
69
  Method: r.Method,
69
- Route: routeTemplate(r),
70
+ Route: web.RoutePattern(r),
70
71
  Status: e.Status,
71
72
  ErrorCode: string(e.Code),
72
- IP: remoteIP(r),
73
+ IP: web.ClientIP(r),
73
74
  UserAgent: r.UserAgent(),
74
- After: after,
75
- }
76
- a.Audit.RecordRejected(r.Context(), ev)
77
- }
78
75
 
79
- // routeTemplate is the matched chi pattern (/api/v1/ideas/{id}), never the raw
80
- // path, so IDs do not leak into the log and rows group by route.
81
- func routeTemplate(r *http.Request) string {
82
- if rc := chi.RouteContext(r.Context()); rc != nil {
83
- return rc.RoutePattern()
76
+ SubmittedEmpNo: e.AuditSubmittedEmpNo,
77
+ After: after,
84
78
  }
85
- return ""
86
- }
87
-
88
- // remoteIP is the TCP peer. Trusted-proxy handling is not decided yet.
89
- func remoteIP(r *http.Request) netip.Addr {
90
- ap, err := netip.ParseAddrPort(r.RemoteAddr)
91
- if err != nil {
92
- return netip.Addr{}
93
- }
94
- return ap.Addr().Unmap()
79
+ a.Audit.RecordRejected(r.Context(), ev)
95
80
  }
@@ -96,3 +96,21 @@ func TestAdapterReasonWithoutActionStillAnswers(t *testing.T) {
96
96
  }
97
97
 
98
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
+ }
@@ -4,7 +4,11 @@
4
4
  // that any package may import it.
5
5
  package security
6
6
 
7
- import "context"
7
+ import (
8
+ "context"
9
+
10
+ "github.com/google/uuid"
11
+ )
8
12
 
9
13
  // Role is an account role (PRD §3). Leader, Member and Applicant are relations
10
14
  // to one Idea, not roles.
@@ -34,7 +38,7 @@ const (
34
38
  // Principal is the caller resolved from a valid session. The role is read from
35
39
  // the account on every request, never copied into the session (PRD §6.5).
36
40
  type Principal struct {
37
- UserID int64
41
+ UserID uuid.UUID
38
42
  EmpNo string
39
43
  Role Role
40
44
  }
@@ -4,6 +4,8 @@ import (
4
4
  "context"
5
5
  "testing"
6
6
 
7
+ "github.com/google/uuid"
8
+
7
9
  "github.com/brian-huanggg/AwardRegistration/backend/internal/shared/security"
8
10
  )
9
11
 
@@ -12,7 +14,7 @@ func TestPrincipalRoundTrip(t *testing.T) {
12
14
  if _, ok := security.PrincipalFrom(ctx); ok {
13
15
  t.Fatal("empty context must have no principal")
14
16
  }
15
- want := security.Principal{UserID: 7, EmpNo: "A123", Role: security.RoleUser}
17
+ want := security.Principal{UserID: uuid.New(), EmpNo: "A123", Role: security.RoleUser}
16
18
  got, ok := security.PrincipalFrom(security.WithPrincipal(ctx, want))
17
19
  if !ok || got != want {
18
20
  t.Fatalf("got %+v, %v", got, ok)
@@ -21,9 +23,9 @@ func TestPrincipalRoundTrip(t *testing.T) {
21
23
 
22
24
  func TestActorRole(t *testing.T) {
23
25
  bg := context.Background()
24
- user := security.Principal{UserID: 1, EmpNo: "U1", Role: security.RoleUser}
25
- admin := security.Principal{UserID: 2, EmpNo: "A1", Role: security.RoleAdmin}
26
- bad := security.Principal{UserID: 3, EmpNo: "X", Role: "ROOT"}
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"}
27
29
 
28
30
  tests := []struct {
29
31
  name string
@@ -41,6 +41,12 @@ type Error struct {
41
41
  // AuditTargetType and AuditTargetID name the resource, when there is one.
42
42
  AuditTargetType string
43
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
44
50
  }
45
51
 
46
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,93 @@
1
+ // Implements PRD §6.2 clean-up and counting. The back end (Go) implements the
2
+ // same rules; both run testdata/text-cleanup.json.
3
+ // String.prototype.trim is NOT used: its whitespace set differs from Unicode
4
+ // White_Space (it strips U+FEFF and keeps U+0085).
5
+
6
+ export type Problem = '' | 'REQUIRED' | 'INVALID_FORMAT' | 'INVALID_NAME_CHARS' | 'OUT_OF_RANGE';
7
+
8
+ export interface CheckResult {
9
+ cleaned: string;
10
+ problem: Problem;
11
+ }
12
+
13
+ /** Unicode White_Space property: 25 code points. */
14
+ export function isWhitespace(cp: number): boolean {
15
+ return (
16
+ (cp >= 0x0009 && cp <= 0x000d) ||
17
+ cp === 0x0020 ||
18
+ cp === 0x0085 ||
19
+ cp === 0x00a0 ||
20
+ cp === 0x1680 ||
21
+ (cp >= 0x2000 && cp <= 0x200a) ||
22
+ cp === 0x2028 ||
23
+ cp === 0x2029 ||
24
+ cp === 0x202f ||
25
+ cp === 0x205f ||
26
+ cp === 0x3000
27
+ );
28
+ }
29
+
30
+ /** Characters that would end up in the expected filename (PRD §6.2 rule 8). */
31
+ const FORBIDDEN_NAME_CHARS = /[/\\:*?"<>|]/;
32
+ const CONTROL = /\p{Cc}/u;
33
+
34
+ /** Length in Unicode code points (not UTF-16 units). */
35
+ export function countCodePoints(s: string): number {
36
+ return Array.from(s).length;
37
+ }
38
+
39
+ /** Removes leading and trailing White_Space code points. */
40
+ export function trimWhitespace(s: string): string {
41
+ const cps = Array.from(s);
42
+ let start = 0;
43
+ let end = cps.length;
44
+ while (start < end && isWhitespace(cps[start]!.codePointAt(0)!)) start++;
45
+ while (end > start && isWhitespace(cps[end - 1]!.codePointAt(0)!)) end--;
46
+ return cps.slice(start, end).join('');
47
+ }
48
+
49
+ /** NFC, trim, then every whitespace run becomes one ASCII space. */
50
+ export function cleanName(s: string): string {
51
+ const trimmed = trimWhitespace(s.normalize('NFC'));
52
+ let out = '';
53
+ let inRun = false;
54
+ for (const ch of trimmed) {
55
+ if (isWhitespace(ch.codePointAt(0)!)) {
56
+ inRun = true;
57
+ continue;
58
+ }
59
+ if (inRun) {
60
+ out += ' ';
61
+ inRun = false;
62
+ }
63
+ out += ch;
64
+ }
65
+ return out;
66
+ }
67
+
68
+ /** NFC, CRLF and CR become LF, then trim. */
69
+ export function cleanSummary(s: string): string {
70
+ return trimWhitespace(s.normalize('NFC').replace(/\r\n/g, '\n').replace(/\r/g, '\n'));
71
+ }
72
+
73
+ function check(cleaned: string, max: number, name: boolean): Problem {
74
+ if (cleaned === '') return 'REQUIRED';
75
+ for (const ch of cleaned) {
76
+ if (CONTROL.test(ch) && (name || ch !== '\n')) return 'INVALID_FORMAT';
77
+ }
78
+ if (name && FORBIDDEN_NAME_CHARS.test(cleaned)) return 'INVALID_NAME_CHARS';
79
+ if (countCodePoints(cleaned) > max) return 'OUT_OF_RANGE';
80
+ return '';
81
+ }
82
+
83
+ /** Cleans a person / Idea name and reports its first problem; `max` is inclusive. */
84
+ export function checkName(s: string, max: number): CheckResult {
85
+ const cleaned = cleanName(s);
86
+ return { cleaned, problem: check(cleaned, max, true) };
87
+ }
88
+
89
+ /** Cleans a summary and reports its first problem; `max` is inclusive. */
90
+ export function checkSummary(s: string, max: number): CheckResult {
91
+ const cleaned = cleanSummary(s);
92
+ return { cleaned, problem: check(cleaned, max, false) };
93
+ }
@@ -0,0 +1,22 @@
1
+ import cleanup from 'testdata/text-cleanup.json';
2
+ import { checkName, checkSummary, countCodePoints, trimWhitespace } from './index';
3
+
4
+ describe('shared/text contract (testdata/text-cleanup.json)', () => {
5
+ it('has cases', () => {
6
+ expect(cleanup.cases.length).toBeGreaterThan(0);
7
+ });
8
+
9
+ it.each(cleanup.cases)('$id', (c) => {
10
+ const r = c.kind === 'name' ? checkName(c.input, c.max) : checkSummary(c.input, c.max);
11
+ expect(r.cleaned).toBe(c.want);
12
+ expect(countCodePoints(r.cleaned)).toBe(c.wantCount);
13
+ expect(r.problem).toBe(c.wantProblem);
14
+ });
15
+ });
16
+
17
+ describe('trimWhitespace', () => {
18
+ it('uses White_Space, not String.prototype.trim', () => {
19
+ expect(trimWhitespace('\u0085a\u0085')).toBe('a');
20
+ expect(trimWhitespace('a')).toBe('a');
21
+ });
22
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@johnnyboy11234/esg-idea-contest",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "ESG Idea Contest registration system: Go backend, React frontend and design documents (source distribution).",
5
5
  "license": "MIT",
6
6
  "author": "Brian Huang",
File without changes