@johnnyboy11234/esg-idea-contest 0.2.1 → 0.2.3

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 (49) hide show
  1. package/backend/api/openapi.yaml +70 -0
  2. package/backend/cmd/server/main.go +17 -1
  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/csrf.go +212 -0
  6. package/backend/internal/auth/csrf_test.go +281 -0
  7. package/backend/internal/auth/internal/store/session.go +63 -5
  8. package/backend/internal/auth/logging_integration_test.go +88 -0
  9. package/backend/internal/auth/login.go +252 -0
  10. package/backend/internal/auth/login_integration_test.go +421 -0
  11. package/backend/internal/auth/login_test.go +68 -0
  12. package/backend/internal/auth/logout.go +68 -0
  13. package/backend/internal/auth/logout_integration_test.go +124 -0
  14. package/backend/internal/auth/me.go +40 -0
  15. package/backend/internal/auth/me_integration_test.go +70 -0
  16. package/backend/internal/auth/middleware.go +66 -0
  17. package/backend/internal/auth/middleware_test.go +153 -0
  18. package/backend/internal/auth/session.go +20 -1
  19. package/backend/internal/server/api.go +19 -0
  20. package/backend/internal/server/public.go +37 -0
  21. package/backend/internal/server/public_test.go +73 -0
  22. package/backend/internal/server/server.go +7 -3
  23. package/backend/internal/shared/config/config.go +23 -4
  24. package/backend/internal/shared/config/config_test.go +14 -8
  25. package/backend/internal/shared/web/problem.go +9 -0
  26. package/backend/internal/shared/web/retryafter_test.go +31 -0
  27. package/frontend/src/app/App.test.tsx +13 -0
  28. package/frontend/src/app/App.tsx +4 -0
  29. package/frontend/src/app/index.css +102 -102
  30. package/frontend/src/app/layouts.tsx +75 -18
  31. package/frontend/src/app/pages/auth.tsx +52 -78
  32. package/frontend/src/app/pages/login.test.tsx +218 -0
  33. package/frontend/src/app/pages/register.test.tsx +231 -0
  34. package/frontend/src/app/router.tsx +83 -34
  35. package/frontend/src/app/session.ts +71 -18
  36. package/frontend/src/features/auth/LoginForm.tsx +145 -0
  37. package/frontend/src/features/auth/RegisterForm.tsx +211 -0
  38. package/frontend/src/features/auth/login.ts +23 -0
  39. package/frontend/src/features/auth/register.ts +105 -0
  40. package/frontend/src/shared/api/csrf.ts +51 -0
  41. package/frontend/src/shared/api/http.test.tsx +209 -0
  42. package/frontend/src/shared/api/http.ts +91 -0
  43. package/frontend/src/shared/api/index.ts +3 -0
  44. package/frontend/src/shared/i18n/locales/en.ts +15 -2
  45. package/frontend/src/shared/i18n/locales/zh-TW.ts +15 -2
  46. package/frontend/src/shared/text/index.ts +20 -0
  47. package/frontend/src/shared/text/text.test.ts +28 -1
  48. package/frontend/vite.config.ts +5 -1
  49. package/package.json +1 -1
@@ -0,0 +1,281 @@
1
+ package auth_test
2
+
3
+ import (
4
+ "context"
5
+ "crypto/rand"
6
+ "encoding/base64"
7
+ "encoding/json"
8
+ "errors"
9
+ "log/slog"
10
+ "net/http"
11
+ "net/http/httptest"
12
+ "strings"
13
+ "testing"
14
+
15
+ "github.com/go-chi/chi/v5"
16
+ "github.com/google/uuid"
17
+
18
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/auth"
19
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/shared/security"
20
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/shared/web"
21
+ )
22
+
23
+ const (
24
+ keyA = "0123456789abcdef0123456789abcdef"
25
+ keyB = "fedcba9876543210fedcba9876543210"
26
+ )
27
+
28
+ func sid(t *testing.T) string {
29
+ t.Helper()
30
+ b := make([]byte, 32)
31
+ if _, err := rand.Read(b); err != nil {
32
+ t.Fatal(err)
33
+ }
34
+ return base64.RawURLEncoding.EncodeToString(b)
35
+ }
36
+
37
+ func mustCSRF(t *testing.T, cur string, prev ...string) *auth.CSRF {
38
+ t.Helper()
39
+ c, err := auth.NewCSRF(cur, prev...)
40
+ if err != nil {
41
+ t.Fatal(err)
42
+ }
43
+ return c
44
+ }
45
+
46
+ type denial struct{ action, reason string }
47
+
48
+ // csrfChain mirrors production order: session middleware, then CSRF, then router.
49
+ func csrfChain(c *auth.CSRF, v auth.Validator, got *denial) http.Handler {
50
+ r := chi.NewRouter()
51
+ r.NotFound(web.NotFound)
52
+ r.MethodNotAllowed(web.MethodNotAllowed)
53
+ ok := func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) }
54
+ r.Get("/auth/csrf", auth.Handler{CSRF: c, Log: slog.New(slog.DiscardHandler)}.GetCsrfToken)
55
+ r.Post("/w", ok)
56
+ r.Put("/w", ok)
57
+ r.Delete("/w", ok)
58
+ r.Post("/auth/logout", ok)
59
+ fail := func(w http.ResponseWriter, req *http.Request, err error) {
60
+ var e *web.Error
61
+ if errors.As(err, &e) && got != nil {
62
+ *got = denial{e.AuditAction, e.AuditReason}
63
+ }
64
+ web.WriteError(w, req, err)
65
+ }
66
+ action := func(req *http.Request) string {
67
+ if req.URL.Path == "/auth/logout" {
68
+ return "AUTH_LOGOUT"
69
+ }
70
+ return ""
71
+ }
72
+ public := func(req *http.Request) bool { return req.URL.Path == "/auth/csrf" || req.URL.Path == "/auth/logout" }
73
+ log := slog.New(slog.DiscardHandler)
74
+ return web.RequestID(auth.Middleware(v, public, log)(c.Middleware(fail, action)(r)))
75
+ }
76
+
77
+ func fetch(t *testing.T, h http.Handler, session string) string {
78
+ t.Helper()
79
+ req := httptest.NewRequest("GET", "/auth/csrf", nil)
80
+ if session != "" {
81
+ req.AddCookie(&http.Cookie{Name: auth.SessionCookie, Value: session})
82
+ }
83
+ rec := httptest.NewRecorder()
84
+ h.ServeHTTP(rec, req)
85
+ if rec.Code != 200 {
86
+ t.Fatalf("GET /auth/csrf = %d %s", rec.Code, rec.Body)
87
+ }
88
+ var body struct{ Token string }
89
+ if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil || body.Token == "" {
90
+ t.Fatalf("body %s: %v", rec.Body, err)
91
+ }
92
+ var ck *http.Cookie
93
+ for _, c := range rec.Result().Cookies() {
94
+ if c.Name == auth.CSRFCookie {
95
+ ck = c
96
+ }
97
+ }
98
+ if ck == nil || ck.Value != body.Token {
99
+ t.Fatalf("cookie %v does not match body token", ck)
100
+ }
101
+ if !ck.HttpOnly || !ck.Secure || ck.Path != "/" || ck.Domain != "" || ck.SameSite != http.SameSiteLaxMode {
102
+ t.Errorf("cookie attributes: %+v", ck)
103
+ }
104
+ if rec.Header().Get("Cache-Control") != "no-store" {
105
+ t.Errorf("Cache-Control = %q", rec.Header().Get("Cache-Control"))
106
+ }
107
+ return body.Token
108
+ }
109
+
110
+ func write(method, path, session, cookie, header string) *http.Request {
111
+ req := httptest.NewRequest(method, path, strings.NewReader("ignored"))
112
+ if session != "" {
113
+ req.AddCookie(&http.Cookie{Name: auth.SessionCookie, Value: session})
114
+ }
115
+ if cookie != "" {
116
+ req.AddCookie(&http.Cookie{Name: auth.CSRFCookie, Value: cookie})
117
+ }
118
+ if header != "" {
119
+ req.Header.Set(auth.CSRFHeader, header)
120
+ }
121
+ return req
122
+ }
123
+
124
+ func TestCSRFWrites(t *testing.T) {
125
+ c := mustCSRF(t, keyA)
126
+ s1, s2 := sid(t), sid(t)
127
+ user := security.Principal{UserID: uuid.New(), EmpNo: "U1", Role: security.RoleUser}
128
+ v := &fakeValidator{live: map[string]security.Principal{s1: user, s2: user}}
129
+ h := csrfChain(c, v, nil)
130
+
131
+ anon, tok1, tok2 := fetch(t, h, ""), fetch(t, h, s1), fetch(t, h, s2)
132
+ other := mustCSRF(t, keyB)
133
+ foreign, _ := other.Issue([]byte("x"))
134
+
135
+ tests := []struct {
136
+ name string
137
+ method, path string
138
+ session, cookie, header string
139
+ status int
140
+ }{
141
+ {"session token, own session", "POST", "/w", s1, tok1, tok1, 204},
142
+ {"anonymous token, no session", "POST", "/auth/logout", "", anon, anon, 204},
143
+ {"PUT and DELETE are checked", "PUT", "/w", s1, "", "", 403},
144
+ {"DELETE with token", "DELETE", "/w", s1, tok1, tok1, 204},
145
+ {"no header", "POST", "/w", s1, tok1, "", 403},
146
+ {"no cookie", "POST", "/w", s1, "", tok1, 403},
147
+ {"cookie and header differ", "POST", "/w", s1, tok1, tok2, 403},
148
+ {"other session's token", "POST", "/w", s1, tok2, tok2, 403},
149
+ {"anonymous token against a session", "POST", "/w", s1, anon, anon, 403},
150
+ {"session token without a session (logout)", "POST", "/auth/logout", "", tok1, tok1, 403},
151
+ {"session token, stale session cookie (logout)", "POST", "/auth/logout", sid(t), tok1, tok1, 403},
152
+ {"signed by an unknown key", "POST", "/w", s1, foreign, foreign, 403},
153
+ {"garbage", "POST", "/w", s1, "abc", "abc", 403},
154
+ {"truncated signature", "POST", "/w", s1, tok1[:len(tok1)-2], tok1[:len(tok1)-2], 403},
155
+ {"no dot", "POST", "/w", s1, strings.ReplaceAll(tok1, ".", ""), strings.ReplaceAll(tok1, ".", ""), 403},
156
+ {"403 beats 404 on unknown path", "POST", "/nope", s1, "", "", 403},
157
+ {"unknown path, valid token, is 404", "POST", "/nope", s1, tok1, tok1, 404},
158
+ {"401 beats 403", "POST", "/w", "", "", "", 401},
159
+ }
160
+ for _, tc := range tests {
161
+ t.Run(tc.name, func(t *testing.T) {
162
+ rec := httptest.NewRecorder()
163
+ h.ServeHTTP(rec, write(tc.method, tc.path, tc.session, tc.cookie, tc.header))
164
+ if rec.Code != tc.status {
165
+ t.Fatalf("status = %d, want %d (%s)", rec.Code, tc.status, rec.Body)
166
+ }
167
+ if tc.status == 403 && !strings.Contains(rec.Body.String(), `"code":"CSRF_INVALID"`) {
168
+ t.Errorf("body %s", rec.Body)
169
+ }
170
+ })
171
+ }
172
+ }
173
+
174
+ func TestCSRFSafeMethodsAreNotChecked(t *testing.T) {
175
+ h := csrfChain(mustCSRF(t, keyA), &fakeValidator{}, nil)
176
+ for _, m := range []string{"GET", "HEAD", "OPTIONS"} {
177
+ rec := httptest.NewRecorder()
178
+ h.ServeHTTP(rec, httptest.NewRequest(m, "/auth/csrf", nil))
179
+ if rec.Code == 403 {
180
+ t.Errorf("%s was CSRF-checked", m)
181
+ }
182
+ }
183
+ }
184
+
185
+ func TestCSRFNeverReadsBody(t *testing.T) {
186
+ h := csrfChain(mustCSRF(t, keyA), &fakeValidator{}, nil)
187
+ req := write("POST", "/auth/logout", "", "", "")
188
+ req.Body = failReader{t}
189
+ h.ServeHTTP(httptest.NewRecorder(), req)
190
+ }
191
+
192
+ type failReader struct{ t *testing.T }
193
+
194
+ func (f failReader) Read([]byte) (int, error) { f.t.Error("body was read"); return 0, context.Canceled }
195
+ func (failReader) Close() error { return nil }
196
+
197
+ func TestCSRFDenialAuditFields(t *testing.T) {
198
+ c := mustCSRF(t, keyA)
199
+ anon := fetch(t, csrfChain(c, &fakeValidator{}, nil), "")
200
+ for _, tc := range []struct{ name, cookie, header, reason string }{
201
+ {"missing", "", "", "csrf_missing"},
202
+ {"mismatch", anon, anon + "x", "csrf_mismatch"},
203
+ {"bad signature", "a.b", "a.b", "csrf_invalid_binding"},
204
+ } {
205
+ var d denial
206
+ rec := httptest.NewRecorder()
207
+ csrfChain(c, &fakeValidator{}, &d).ServeHTTP(rec, write("POST", "/auth/logout", "", tc.cookie, tc.header))
208
+ if rec.Code != 403 || d != (denial{"AUTH_LOGOUT", tc.reason}) {
209
+ t.Errorf("%s: status %d, audit %+v", tc.name, rec.Code, d)
210
+ }
211
+ }
212
+ // Unrouted write: denied, but nothing to audit.
213
+ var d denial
214
+ csrfChain(c, &fakeValidator{live: map[string]security.Principal{"x": {}}}, &d).
215
+ ServeHTTP(httptest.NewRecorder(), write("POST", "/w", "", "", ""))
216
+ if d != (denial{}) {
217
+ t.Errorf("unknown action was audited: %+v", d)
218
+ }
219
+ }
220
+
221
+ func TestCSRFTokensAreFresh(t *testing.T) {
222
+ h := csrfChain(mustCSRF(t, keyA), &fakeValidator{}, nil)
223
+ first, second := fetch(t, h, ""), fetch(t, h, "")
224
+ if first == second {
225
+ t.Error("two tokens are equal")
226
+ }
227
+ }
228
+
229
+ func TestCSRFKeyRotation(t *testing.T) {
230
+ b := []byte("binding")
231
+ old := mustCSRF(t, keyA)
232
+ tok, _ := old.Issue(b)
233
+
234
+ rotated := mustCSRF(t, keyB, keyA)
235
+ if !rotated.Verify(tok, b) {
236
+ t.Error("previous key must still verify")
237
+ }
238
+ if fresh, _ := rotated.Issue(b); !mustCSRF(t, keyB).Verify(fresh, b) || old.Verify(fresh, b) {
239
+ t.Error("new tokens must be signed by the current key only")
240
+ }
241
+ if mustCSRF(t, keyB).Verify(tok, b) {
242
+ t.Error("after the previous key is dropped the old token must fail")
243
+ }
244
+ if !mustCSRF(t, keyB, "").Verify(mustTok(t, mustCSRF(t, keyB), b), b) {
245
+ t.Error("empty previous key means none")
246
+ }
247
+ if old.Verify(tok, []byte("other")) {
248
+ t.Error("binding must be part of the signature")
249
+ }
250
+ }
251
+
252
+ func mustTok(t *testing.T, c *auth.CSRF, b []byte) string {
253
+ t.Helper()
254
+ tok, err := c.Issue(b)
255
+ if err != nil {
256
+ t.Fatal(err)
257
+ }
258
+ return tok
259
+ }
260
+
261
+ func TestNewCSRFRejectsShortKeys(t *testing.T) {
262
+ if _, err := auth.NewCSRF("short"); err == nil {
263
+ t.Error("short current key accepted")
264
+ }
265
+ if _, err := auth.NewCSRF(keyA, "short"); err == nil {
266
+ t.Error("short previous key accepted")
267
+ }
268
+ }
269
+
270
+ func TestCSRFSessionWithUnbindableIDIs500(t *testing.T) {
271
+ // A validator that accepts an ID Binding cannot parse is a wiring bug: fail closed.
272
+ v := &fakeValidator{live: map[string]security.Principal{"not-an-id": {Role: security.RoleUser}}}
273
+ h := csrfChain(mustCSRF(t, keyA), v, nil)
274
+ req := httptest.NewRequest("GET", "/auth/csrf", nil)
275
+ req.AddCookie(&http.Cookie{Name: auth.SessionCookie, Value: "not-an-id"})
276
+ rec := httptest.NewRecorder()
277
+ h.ServeHTTP(rec, req)
278
+ if rec.Code != 500 {
279
+ t.Errorf("status = %d", rec.Code)
280
+ }
281
+ }
@@ -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
+ }