@johnnyboy11234/esg-idea-contest 0.2.2 → 0.2.4

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 (35) hide show
  1. package/backend/api/openapi.yaml +70 -0
  2. package/backend/cmd/server/main.go +10 -2
  3. package/backend/internal/account/logging_integration_test.go +70 -0
  4. package/backend/internal/api/api.gen.go +125 -0
  5. package/backend/internal/auth/internal/store/session.go +63 -5
  6. package/backend/internal/auth/logging_integration_test.go +88 -0
  7. package/backend/internal/auth/login.go +252 -0
  8. package/backend/internal/auth/login_integration_test.go +421 -0
  9. package/backend/internal/auth/login_test.go +68 -0
  10. package/backend/internal/auth/logout.go +68 -0
  11. package/backend/internal/auth/logout_integration_test.go +124 -0
  12. package/backend/internal/auth/me.go +40 -0
  13. package/backend/internal/auth/me_integration_test.go +70 -0
  14. package/backend/internal/auth/session.go +20 -1
  15. package/backend/internal/server/api.go +14 -0
  16. package/backend/internal/server/public.go +3 -1
  17. package/backend/internal/shared/web/problem.go +9 -0
  18. package/backend/internal/shared/web/retryafter_test.go +31 -0
  19. package/frontend/src/app/index.css +102 -102
  20. package/frontend/src/app/layouts.tsx +75 -18
  21. package/frontend/src/app/pages/auth.tsx +52 -78
  22. package/frontend/src/app/pages/login.test.tsx +218 -0
  23. package/frontend/src/app/pages/register.test.tsx +231 -0
  24. package/frontend/src/app/router.tsx +83 -34
  25. package/frontend/src/app/session.ts +53 -22
  26. package/frontend/src/features/auth/LoginForm.tsx +145 -0
  27. package/frontend/src/features/auth/RegisterForm.tsx +211 -0
  28. package/frontend/src/features/auth/login.ts +23 -0
  29. package/frontend/src/features/auth/register.ts +105 -0
  30. package/frontend/src/shared/i18n/locales/en.ts +15 -2
  31. package/frontend/src/shared/i18n/locales/zh-TW.ts +15 -2
  32. package/frontend/src/shared/text/index.ts +20 -0
  33. package/frontend/src/shared/text/text.test.ts +28 -1
  34. package/frontend/vite.config.ts +5 -1
  35. package/package.json +1 -1
@@ -29,6 +29,49 @@ paths:
29
29
  schema: { $ref: '#/components/schemas/CsrfTokenResponse' }
30
30
  '429': { $ref: '#/components/responses/Problem' }
31
31
  '500': { $ref: '#/components/responses/Problem' }
32
+ /auth/login:
33
+ post:
34
+ operationId: login
35
+ tags: [auth]
36
+ summary: Log in with employee ID and password (issues a new session)
37
+ description: >
38
+ Sets the session cookie and returns the caller. Unknown, malformed and disabled
39
+ accounts and wrong passwords all answer the same 401. The CSRF token changes
40
+ with the session, so the client refetches it.
41
+ security: []
42
+ parameters:
43
+ - $ref: '#/components/parameters/CsrfToken'
44
+ requestBody:
45
+ required: true
46
+ content:
47
+ application/json:
48
+ schema: { $ref: '#/components/schemas/LoginRequest' }
49
+ responses:
50
+ '200':
51
+ description: Logged in
52
+ headers:
53
+ Set-Cookie:
54
+ description: __Host-session=<opaque ID>; HttpOnly; Secure; SameSite=Lax; Path=/
55
+ schema: { type: string }
56
+ content:
57
+ application/json:
58
+ schema: { $ref: '#/components/schemas/LoginResponse' }
59
+ '400': { $ref: '#/components/responses/Problem' }
60
+ '401': { $ref: '#/components/responses/Problem' }
61
+ '403': { $ref: '#/components/responses/Problem' }
62
+ '413': { $ref: '#/components/responses/Problem' }
63
+ '415': { $ref: '#/components/responses/Problem' }
64
+ '429':
65
+ description: Throttled (LOGIN_THROTTLED)
66
+ headers:
67
+ Retry-After:
68
+ description: Seconds until a new attempt may succeed
69
+ schema: { type: integer, minimum: 1 }
70
+ content:
71
+ application/problem+json:
72
+ schema: { $ref: '#/components/schemas/Problem' }
73
+ '500': { $ref: '#/components/responses/Problem' }
74
+ '503': { $ref: '#/components/responses/Problem' }
32
75
  /auth/logout:
33
76
  post:
34
77
  operationId: logout
@@ -41,6 +84,19 @@ paths:
41
84
  '204': { description: Logged out }
42
85
  '403': { $ref: '#/components/responses/Problem' }
43
86
  '500': { $ref: '#/components/responses/Problem' }
87
+ /auth/me:
88
+ get:
89
+ operationId: getMe
90
+ tags: [auth]
91
+ summary: The caller (also the idle-timeout "continue" call)
92
+ responses:
93
+ '200':
94
+ description: The authenticated caller
95
+ content:
96
+ application/json:
97
+ schema: { $ref: '#/components/schemas/LoginResponse' }
98
+ '401': { $ref: '#/components/responses/Problem' }
99
+ '500': { $ref: '#/components/responses/Problem' }
44
100
  /auth/register:
45
101
  post:
46
102
  operationId: register
@@ -154,6 +210,20 @@ components:
154
210
  required: [token]
155
211
  properties:
156
212
  token: { type: string }
213
+ LoginRequest:
214
+ type: object
215
+ required: [empNo, password]
216
+ properties:
217
+ empNo: { type: string, description: 'Any string; a badly formatted ID is an ordinary login failure' }
218
+ password: { type: string, format: password }
219
+ LoginResponse:
220
+ type: object
221
+ required: [empNo, name, email, role]
222
+ properties:
223
+ empNo: { type: string }
224
+ name: { type: string }
225
+ email: { type: string, nullable: true, description: null for Admin }
226
+ role: { type: string, enum: [USER, ADMIN] }
157
227
  RegisterRequest:
158
228
  type: object
159
229
  required: [empNo, name, email, password]
@@ -51,8 +51,16 @@ func run() error {
51
51
  if err != nil {
52
52
  return err
53
53
  }
54
+ sessions := auth.New(db)
55
+ logins, err := auth.NewLogins(sessions, auditSvc, auth.NoThrottle{}) // B1b-04 wires the real throttle
56
+ if err != nil {
57
+ return err
58
+ }
54
59
  si := server.API{
55
- Auth: auth.Handler{CSRF: csrf, Log: log},
60
+ Auth: auth.Handler{CSRF: csrf, Log: log},
61
+ Logins: auth.LoginHandler{Logins: logins, Fail: errs.Write},
62
+ Logouts: auth.LogoutHandler{Logouts: &auth.Logouts{Sessions: sessions, Audit: auditSvc}, Fail: errs.Write},
63
+ Me: auth.MeHandler{Sessions: sessions, Fail: errs.Write},
56
64
  Account: account.Handler{
57
65
  Service: &account.Service{DB: db, Audit: auditSvc, Policy: account.StubPolicy{}},
58
66
  Fail: errs.Write,
@@ -62,6 +70,6 @@ func run() error {
62
70
 
63
71
  var ready atomic.Bool
64
72
  return server.Run(ctx, cfg, log, server.NewPublic(log, server.Mount(si),
65
- auth.Middleware(auth.New(db), server.IsPublic, log),
73
+ auth.Middleware(sessions, server.IsPublic, log),
66
74
  csrf.Middleware(errs.Write, server.ActionFor)), &ready, server.DBCheck(db))
67
75
  }
@@ -0,0 +1,70 @@
1
+ //go:build integration
2
+
3
+ package account_test
4
+
5
+ import (
6
+ "bytes"
7
+ "log/slog"
8
+ "strings"
9
+ "sync"
10
+ "testing"
11
+
12
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/account"
13
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/audit"
14
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/server"
15
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/shared/dbtest"
16
+ "github.com/brian-huanggg/AwardRegistration/backend/test/apispec"
17
+ )
18
+
19
+ type syncBuf struct {
20
+ mu sync.Mutex
21
+ b bytes.Buffer
22
+ }
23
+
24
+ func (s *syncBuf) Write(p []byte) (int, error) {
25
+ s.mu.Lock()
26
+ defer s.mu.Unlock()
27
+ return s.b.Write(p)
28
+ }
29
+
30
+ func (s *syncBuf) String() string {
31
+ s.mu.Lock()
32
+ defer s.mu.Unlock()
33
+ return s.b.String()
34
+ }
35
+
36
+ // TestRegisterLogsNeverContainPassword drives register (created, duplicate,
37
+ // weak password, malformed JSON) at DEBUG level and asserts the submitted
38
+ // password never reaches a log line (PRD §6.5 / §8.2).
39
+ func TestRegisterLogsNeverContainPassword(t *testing.T) {
40
+ buf := &syncBuf{}
41
+ log := slog.New(slog.NewJSONHandler(buf, &slog.HandlerOptions{Level: slog.LevelDebug}))
42
+ db := dbtest.New(t)
43
+ auditSvc := audit.New(db, log)
44
+ errs := server.ErrorAdapter{Audit: auditSvc, Log: log}
45
+ api := server.API{Account: account.Handler{
46
+ Service: &account.Service{DB: db, Audit: auditSvc, Policy: account.StubPolicy{}},
47
+ Fail: errs.Write,
48
+ }}
49
+ e := env{db: db, h: apispec.Load(t).Validate(t, server.NewPublic(log, server.Mount(api)))}
50
+
51
+ const (
52
+ pw1 = "Sup3r-Secret-Horse-1"
53
+ pw2 = "An0ther-Secret-Horse-2"
54
+ pw3 = "Dup-Secret-Horse-3"
55
+ )
56
+ e.register(reg("089290", "Wang", "wang@example.com", pw1)) // 201
57
+ e.register(reg("089290", "Wang", "wang2@example.com", pw2)) // duplicate
58
+ e.register(reg("089291", "Lee", "lee@example.com", "short")) // weak
59
+ e.register(`{"empNo":"089292","name":"X","email":"x@example.com","password":"` + pw3 + `"`) // malformed
60
+
61
+ out := buf.String()
62
+ if strings.TrimSpace(out) == "" {
63
+ t.Fatal("no log output captured; the test would prove nothing")
64
+ }
65
+ for _, s := range []string{pw1, pw2, pw3} {
66
+ if strings.Contains(out, s) {
67
+ t.Errorf("log contains password %q", s)
68
+ }
69
+ }
70
+ }
@@ -53,6 +53,24 @@ func (e IdeaSummaryMyRelation) Valid() bool {
53
53
  }
54
54
  }
55
55
 
56
+ // Defines values for LoginResponseRole.
57
+ const (
58
+ ADMIN LoginResponseRole = "ADMIN"
59
+ USER LoginResponseRole = "USER"
60
+ )
61
+
62
+ // Valid indicates whether the value is a known member of the LoginResponseRole enum.
63
+ func (e LoginResponseRole) Valid() bool {
64
+ switch e {
65
+ case ADMIN:
66
+ return true
67
+ case USER:
68
+ return true
69
+ default:
70
+ return false
71
+ }
72
+ }
73
+
56
74
  // CsrfTokenResponse defines model for CsrfTokenResponse.
57
75
  type CsrfTokenResponse struct {
58
76
  Token string `json:"token"`
@@ -140,6 +158,25 @@ type IdeaSummary struct {
140
158
  // IdeaSummaryMyRelation null for Admin
141
159
  type IdeaSummaryMyRelation string
142
160
 
161
+ // LoginRequest defines model for LoginRequest.
162
+ type LoginRequest struct {
163
+ // EmpNo Any string; a badly formatted ID is an ordinary login failure
164
+ EmpNo string `json:"empNo"`
165
+ Password string `json:"password"`
166
+ }
167
+
168
+ // LoginResponse defines model for LoginResponse.
169
+ type LoginResponse struct {
170
+ // Email null for Admin
171
+ Email *string `json:"email"`
172
+ EmpNo string `json:"empNo"`
173
+ Name string `json:"name"`
174
+ Role LoginResponseRole `json:"role"`
175
+ }
176
+
177
+ // LoginResponseRole defines model for LoginResponse.Role.
178
+ type LoginResponseRole string
179
+
143
180
  // PersonRef defines model for PersonRef.
144
181
  type PersonRef struct {
145
182
  EmpNo string `json:"empNo"`
@@ -186,6 +223,12 @@ type CsrfToken = string
186
223
  // IdeaId defines model for IdeaId.
187
224
  type IdeaId = openapi_types.UUID
188
225
 
226
+ // LoginParams defines parameters for Login.
227
+ type LoginParams struct {
228
+ // XCSRFToken Session-bound token from GET /auth/csrf. Mandatory on every write, but enforced by the CSRF middleware (403 CSRF_INVALID), so the generated wrapper must not reject its absence first (400).
229
+ XCSRFToken *CsrfToken `json:"X-CSRF-Token,omitempty"`
230
+ }
231
+
189
232
  // LogoutParams defines parameters for Logout.
190
233
  type LogoutParams struct {
191
234
  // XCSRFToken Session-bound token from GET /auth/csrf. Mandatory on every write, but enforced by the CSRF middleware (403 CSRF_INVALID), so the generated wrapper must not reject its absence first (400).
@@ -204,6 +247,9 @@ type ListIdeasParams struct {
204
247
  Size *int `form:"size,omitempty" json:"size,omitempty"`
205
248
  }
206
249
 
250
+ // LoginJSONRequestBody defines body for Login for application/json ContentType.
251
+ type LoginJSONRequestBody = LoginRequest
252
+
207
253
  // RegisterJSONRequestBody defines body for Register for application/json ContentType.
208
254
  type RegisterJSONRequestBody = RegisterRequest
209
255
 
@@ -212,9 +258,15 @@ type ServerInterface interface {
212
258
  // GetCsrfToken Session-bound CSRF token (anonymous before login)
213
259
  // (GET /auth/csrf)
214
260
  GetCsrfToken(w http.ResponseWriter, r *http.Request)
261
+ // Login Log in with employee ID and password (issues a new session)
262
+ // (POST /auth/login)
263
+ Login(w http.ResponseWriter, r *http.Request, params LoginParams)
215
264
  // Logout End the session
216
265
  // (POST /auth/logout)
217
266
  Logout(w http.ResponseWriter, r *http.Request, params LogoutParams)
267
+ // GetMe The caller (also the idle-timeout "continue" call)
268
+ // (GET /auth/me)
269
+ GetMe(w http.ResponseWriter, r *http.Request)
218
270
  // Register Self-service registration (no session is created)
219
271
  // (POST /auth/register)
220
272
  Register(w http.ResponseWriter, r *http.Request, params RegisterParams)
@@ -239,12 +291,24 @@ func (_ Unimplemented) GetCsrfToken(w http.ResponseWriter, r *http.Request) {
239
291
  w.WriteHeader(http.StatusNotImplemented)
240
292
  }
241
293
 
294
+ // Login Log in with employee ID and password (issues a new session)
295
+ // (POST /auth/login)
296
+ func (_ Unimplemented) Login(w http.ResponseWriter, r *http.Request, params LoginParams) {
297
+ w.WriteHeader(http.StatusNotImplemented)
298
+ }
299
+
242
300
  // Logout End the session
243
301
  // (POST /auth/logout)
244
302
  func (_ Unimplemented) Logout(w http.ResponseWriter, r *http.Request, params LogoutParams) {
245
303
  w.WriteHeader(http.StatusNotImplemented)
246
304
  }
247
305
 
306
+ // GetMe The caller (also the idle-timeout "continue" call)
307
+ // (GET /auth/me)
308
+ func (_ Unimplemented) GetMe(w http.ResponseWriter, r *http.Request) {
309
+ w.WriteHeader(http.StatusNotImplemented)
310
+ }
311
+
248
312
  // Register Self-service registration (no session is created)
249
313
  // (POST /auth/register)
250
314
  func (_ Unimplemented) Register(w http.ResponseWriter, r *http.Request, params RegisterParams) {
@@ -292,6 +356,47 @@ func (siw *ServerInterfaceWrapper) GetCsrfToken(w http.ResponseWriter, r *http.R
292
356
  handler.ServeHTTP(w, r)
293
357
  }
294
358
 
359
+ // Login operation middleware
360
+ func (siw *ServerInterfaceWrapper) Login(w http.ResponseWriter, r *http.Request) {
361
+
362
+ var err error
363
+ _ = err
364
+
365
+ // Parameter object where we will unmarshal all parameters from the context
366
+ var params LoginParams
367
+
368
+ headers := r.Header
369
+
370
+ // ------------- Optional header parameter "X-CSRF-Token" -------------
371
+ if valueList, found := headers[http.CanonicalHeaderKey("X-CSRF-Token")]; found {
372
+ var XCSRFToken CsrfToken
373
+ n := len(valueList)
374
+ if n != 1 {
375
+ siw.ErrorHandlerFunc(w, r, &TooManyValuesForParamError{ParamName: "X-CSRF-Token", Count: n})
376
+ return
377
+ }
378
+
379
+ err = runtime.BindStyledParameterWithOptions("simple", "X-CSRF-Token", valueList[0], &XCSRFToken, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationHeader, Explode: false, Required: false, Type: "string", Format: ""})
380
+ if err != nil {
381
+ siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "X-CSRF-Token", Err: err})
382
+ return
383
+ }
384
+
385
+ params.XCSRFToken = &XCSRFToken
386
+
387
+ }
388
+
389
+ handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
390
+ siw.Handler.Login(w, r, params)
391
+ }))
392
+
393
+ for _, middleware := range siw.HandlerMiddlewares {
394
+ handler = middleware(handler)
395
+ }
396
+
397
+ handler.ServeHTTP(w, r)
398
+ }
399
+
295
400
  // Logout operation middleware
296
401
  func (siw *ServerInterfaceWrapper) Logout(w http.ResponseWriter, r *http.Request) {
297
402
 
@@ -333,6 +438,20 @@ func (siw *ServerInterfaceWrapper) Logout(w http.ResponseWriter, r *http.Request
333
438
  handler.ServeHTTP(w, r)
334
439
  }
335
440
 
441
+ // GetMe operation middleware
442
+ func (siw *ServerInterfaceWrapper) GetMe(w http.ResponseWriter, r *http.Request) {
443
+
444
+ handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
445
+ siw.Handler.GetMe(w, r)
446
+ }))
447
+
448
+ for _, middleware := range siw.HandlerMiddlewares {
449
+ handler = middleware(handler)
450
+ }
451
+
452
+ handler.ServeHTTP(w, r)
453
+ }
454
+
336
455
  // Register operation middleware
337
456
  func (siw *ServerInterfaceWrapper) Register(w http.ResponseWriter, r *http.Request) {
338
457
 
@@ -576,9 +695,15 @@ func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handl
576
695
  r.Group(func(r chi.Router) {
577
696
  r.Get(options.BaseURL+"/auth/csrf", wrapper.GetCsrfToken)
578
697
  })
698
+ r.Group(func(r chi.Router) {
699
+ r.Post(options.BaseURL+"/auth/login", wrapper.Login)
700
+ })
579
701
  r.Group(func(r chi.Router) {
580
702
  r.Post(options.BaseURL+"/auth/logout", wrapper.Logout)
581
703
  })
704
+ r.Group(func(r chi.Router) {
705
+ r.Get(options.BaseURL+"/auth/me", wrapper.GetMe)
706
+ })
582
707
  r.Group(func(r chi.Router) {
583
708
  r.Post(options.BaseURL+"/auth/register", wrapper.Register)
584
709
  })
@@ -20,9 +20,14 @@ type Limits struct {
20
20
  RefreshSeconds int
21
21
  }
22
22
 
23
+ // Execer is *sql.DB or *sql.Tx.
24
+ type Execer interface {
25
+ ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
26
+ }
27
+
23
28
  // Insert stores a new session row. hash is SHA-256 of the session ID.
24
- func Insert(ctx context.Context, db *sql.DB, hash []byte, userID uuid.UUID) error {
25
- _, err := db.ExecContext(ctx,
29
+ func Insert(ctx context.Context, x Execer, hash []byte, userID uuid.UUID) error {
30
+ _, err := x.ExecContext(ctx,
26
31
  `INSERT INTO session (id_hash, user_id) VALUES (?, ?)`, hash, userID.String())
27
32
  if err != nil {
28
33
  return fmt.Errorf("insert session: %w", err)
@@ -77,9 +82,10 @@ func Principal(ctx context.Context, db *sql.DB, hash []byte) (p security.Princip
77
82
  return p, true, nil
78
83
  }
79
84
 
80
- // Delete removes one session; deleting a missing one is not an error.
81
- func Delete(ctx context.Context, db *sql.DB, hash []byte) error {
82
- if _, err := db.ExecContext(ctx, `DELETE FROM session WHERE id_hash = ?`, hash); err != nil {
85
+ // Delete removes one session; deleting a missing one is not an error. x is the
86
+ // *sql.DB, or the caller's *sql.Tx so logout commits with its audit row.
87
+ func Delete(ctx context.Context, x Execer, hash []byte) error {
88
+ if _, err := x.ExecContext(ctx, `DELETE FROM session WHERE id_hash = ?`, hash); err != nil {
83
89
  return fmt.Errorf("delete session: %w", err)
84
90
  }
85
91
  return nil
@@ -109,3 +115,55 @@ func DeleteExpired(ctx context.Context, db *sql.DB, l Limits) (int64, error) {
109
115
  }
110
116
  return n, nil
111
117
  }
118
+
119
+ // Account is the login view of app_user: what is needed to check a password
120
+ // and answer the login response. PasswordHash never leaves the auth module.
121
+ type Account struct {
122
+ ID uuid.UUID
123
+ EmpNo string
124
+ Name string
125
+ Email *string // NULL for Admin
126
+ PasswordHash string
127
+ Role security.Role
128
+ Enabled bool
129
+ }
130
+
131
+ // AccountByEmpNo reads one account by employee ID; found is false when none
132
+ // exists.
133
+ func AccountByEmpNo(ctx context.Context, db *sql.DB, empNo string) (a Account, found bool, err error) {
134
+ var id, role string
135
+ err = db.QueryRowContext(ctx,
136
+ `SELECT id, emp_no, name, email, password_hash, role, enabled
137
+ FROM app_user WHERE emp_no = ?`, empNo).
138
+ Scan(&id, &a.EmpNo, &a.Name, &a.Email, &a.PasswordHash, &role, &a.Enabled)
139
+ if errors.Is(err, sql.ErrNoRows) {
140
+ return Account{}, false, nil
141
+ }
142
+ if err != nil {
143
+ return Account{}, false, fmt.Errorf("read account: %w", err)
144
+ }
145
+ if a.ID, err = uuid.Parse(id); err != nil {
146
+ return Account{}, false, fmt.Errorf("parse user id %q: %w", id, err)
147
+ }
148
+ a.Role = security.Role(role)
149
+ return a, true, nil
150
+ }
151
+
152
+ // AccountByID reads one account by its user ID; found is false when none
153
+ // exists.
154
+ func AccountByID(ctx context.Context, db *sql.DB, id uuid.UUID) (a Account, found bool, err error) {
155
+ var sid, role string
156
+ err = db.QueryRowContext(ctx,
157
+ `SELECT id, emp_no, name, email, password_hash, role, enabled
158
+ FROM app_user WHERE id = ?`, id.String()).
159
+ Scan(&sid, &a.EmpNo, &a.Name, &a.Email, &a.PasswordHash, &role, &a.Enabled)
160
+ if errors.Is(err, sql.ErrNoRows) {
161
+ return Account{}, false, nil
162
+ }
163
+ if err != nil {
164
+ return Account{}, false, fmt.Errorf("read account by id: %w", err)
165
+ }
166
+ a.ID = id
167
+ a.Role = security.Role(role)
168
+ return a, true, nil
169
+ }
@@ -0,0 +1,88 @@
1
+ //go:build integration
2
+
3
+ package auth_test
4
+
5
+ import (
6
+ "bytes"
7
+ "log/slog"
8
+ "net/http"
9
+ "net/http/httptest"
10
+ "strings"
11
+ "sync"
12
+ "testing"
13
+
14
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/auth"
15
+ )
16
+
17
+ type syncBuf struct {
18
+ mu sync.Mutex
19
+ b bytes.Buffer
20
+ }
21
+
22
+ func (s *syncBuf) Write(p []byte) (int, error) {
23
+ s.mu.Lock()
24
+ defer s.mu.Unlock()
25
+ return s.b.Write(p)
26
+ }
27
+
28
+ func (s *syncBuf) String() string {
29
+ s.mu.Lock()
30
+ defer s.mu.Unlock()
31
+ return s.b.String()
32
+ }
33
+
34
+ // TestLogsNeverContainSecrets drives login (good and bad), csrf, me, logout and
35
+ // a rejected write at DEBUG level and asserts no secret reaches any log line
36
+ // (PRD §6.5 / §8.2: no password, session ID, CSRF token or Cookie in logs).
37
+ func TestLogsNeverContainSecrets(t *testing.T) {
38
+ buf := &syncBuf{}
39
+ e := newLoginEnvLog(t, slog.New(slog.NewJSONHandler(buf, &slog.HandlerOptions{Level: slog.LevelDebug})))
40
+ e.addUser(t, "123456", "USER", true)
41
+
42
+ const wrongPW = "Wr0ng-Secret-Passw0rd!"
43
+ secrets := []string{password, wrongPW}
44
+
45
+ e.login(t, creds("123456", wrongPW))
46
+ e.login(t, creds("999999", wrongPW))
47
+ e.login(t, `{"empNo":"123456","password":"`+wrongPW+`"`) // malformed JSON echoing the password
48
+
49
+ rec := e.login(t, creds("123456", password))
50
+ sess := findCookie(rec, auth.SessionCookie)
51
+ if sess == nil {
52
+ t.Fatalf("login failed: %d", rec.Code)
53
+ }
54
+ secrets = append(secrets, sess.Value)
55
+
56
+ tok, ck := e.csrf(t, sess)
57
+ secrets = append(secrets, tok, ck.Value)
58
+
59
+ if rec := e.me(t, true, sess.Value); rec.Code != 200 {
60
+ t.Fatalf("me: %d", rec.Code)
61
+ }
62
+ // Write with a bad CSRF token: denied, and the submitted token must not be logged.
63
+ bad := httptest.NewRequest("POST", "/api/v1/auth/logout", nil)
64
+ bad.Header.Set("X-CSRF-Token", "forged-token-value-123")
65
+ bad.AddCookie(sess)
66
+ bad.AddCookie(&http.Cookie{Name: "__Host-csrf", Value: "forged-cookie-value-456"})
67
+ e.do(bad)
68
+ secrets = append(secrets, "forged-token-value-123", "forged-cookie-value-456")
69
+
70
+ if rec := e.logout(tok, ck, sess); rec.Code != 204 {
71
+ t.Fatalf("logout: %d", rec.Code)
72
+ }
73
+
74
+ out := buf.String()
75
+ if strings.TrimSpace(out) == "" {
76
+ t.Fatal("no log output captured; the test would prove nothing")
77
+ }
78
+ for _, s := range secrets {
79
+ if s != "" && strings.Contains(out, s) {
80
+ t.Errorf("log contains secret %q", s)
81
+ }
82
+ }
83
+ for _, name := range []string{"Set-Cookie", "set-cookie", "__Host-session=", "X-CSRF-Token"} {
84
+ if strings.Contains(out, name) {
85
+ t.Errorf("log contains header material %q", name)
86
+ }
87
+ }
88
+ }