@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,119 @@
1
+ // Package audit writes the Audit Log (PRD §6.11, Backend.md §7). Two tracks:
2
+ //
3
+ // - Record joins the caller's transaction, so a successful write and its audit
4
+ // row commit or roll back together (fail-closed).
5
+ // - RecordRejected opens its own transaction for DENIED / FAILED / THROTTLED
6
+ // rows; it is best effort and counts its failures.
7
+ //
8
+ // The table is append-only: this package only ever INSERTs.
9
+ package audit
10
+
11
+ import (
12
+ "encoding/json"
13
+ "net/netip"
14
+
15
+ "github.com/google/uuid"
16
+
17
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/shared/security"
18
+ )
19
+
20
+ // Action is the audit action (PRD §6.11).
21
+ type Action string
22
+
23
+ // The recorded actions.
24
+ const (
25
+ AuthRegister Action = "AUTH_REGISTER"
26
+ AuthLogin Action = "AUTH_LOGIN"
27
+ AuthLogout Action = "AUTH_LOGOUT"
28
+ IdeaCreate Action = "IDEA_CREATE"
29
+ IdeaUpdate Action = "IDEA_UPDATE"
30
+ IdeaDelete Action = "IDEA_DELETE"
31
+ IdeaView Action = "IDEA_VIEW"
32
+ SlidesReplace Action = "SLIDES_REPLACE"
33
+ SlidesDownload Action = "SLIDES_DOWNLOAD"
34
+ JoinRequestCreate Action = "JOIN_REQUEST_CREATE"
35
+ JoinRequestApprove Action = "JOIN_REQUEST_APPROVE"
36
+ JoinRequestReject Action = "JOIN_REQUEST_REJECT"
37
+ JoinRequestWithdraw Action = "JOIN_REQUEST_WITHDRAW"
38
+ MemberRemove Action = "MEMBER_REMOVE"
39
+ MemberLeave Action = "MEMBER_LEAVE"
40
+ DeadlineUpdate Action = "DEADLINE_UPDATE"
41
+ AdminSync Action = "ADMIN_SYNC"
42
+ AuditQuery Action = "AUDIT_QUERY"
43
+ )
44
+
45
+ var actions = map[Action]bool{
46
+ AuthRegister: true, AuthLogin: true, AuthLogout: true,
47
+ IdeaCreate: true, IdeaUpdate: true, IdeaDelete: true, IdeaView: true,
48
+ SlidesReplace: true, SlidesDownload: true,
49
+ JoinRequestCreate: true, JoinRequestApprove: true, JoinRequestReject: true, JoinRequestWithdraw: true,
50
+ MemberRemove: true, MemberLeave: true,
51
+ DeadlineUpdate: true, AdminSync: true, AuditQuery: true,
52
+ }
53
+
54
+ // Valid reports whether a is a known action.
55
+ func (a Action) Valid() bool { return actions[a] }
56
+
57
+ // Outcome is the result of the audited operation.
58
+ type Outcome string
59
+
60
+ // The outcomes. SUCCESS goes through Record; the other three are rejected
61
+ // attempts and go through RecordRejected (ADMIN_SYNC FAILED may use either).
62
+ const (
63
+ Success Outcome = "SUCCESS"
64
+ Failed Outcome = "FAILED"
65
+ Denied Outcome = "DENIED"
66
+ Throttled Outcome = "THROTTLED"
67
+ )
68
+
69
+ // Valid reports whether o is a known outcome.
70
+ func (o Outcome) Valid() bool {
71
+ return o == Success || o == Failed || o == Denied || o == Throttled
72
+ }
73
+
74
+ // TargetType is the kind of resource a row is about.
75
+ type TargetType string
76
+
77
+ // The target types. DEADLINE has no ID.
78
+ const (
79
+ TargetUser TargetType = "USER"
80
+ TargetIdea TargetType = "IDEA"
81
+ TargetDeadline TargetType = "DEADLINE"
82
+ )
83
+
84
+ // Valid reports whether t is a known target type.
85
+ func (t TargetType) Valid() bool {
86
+ return t == TargetUser || t == TargetIdea || t == TargetDeadline
87
+ }
88
+
89
+ // Event is one audit row. The request ID and, unless overridden, the actor come
90
+ // from the context; occurred_at is the database clock.
91
+ type Event struct {
92
+ Action Action
93
+ Outcome Outcome
94
+
95
+ // Actor override. Needed where the context has no principal yet (a login
96
+ // that just succeeded) or the actor is not the caller (ADMIN_SYNC). When
97
+ // ActorRole is empty both fields come from the context.
98
+ ActorRole security.ActorRole
99
+ ActorEmpNo string
100
+
101
+ TargetType TargetType
102
+ TargetID uuid.UUID // uuid.Nil = none
103
+
104
+ Method string
105
+ Route string // route template, e.g. /ideas/{id}
106
+ Status int // HTTP status; 0 = none
107
+ ErrorCode string
108
+ IP netip.Addr // zero = none
109
+ UserAgent string
110
+
111
+ // SubmittedEmpNo is the raw employee ID typed into a failed login or
112
+ // registration; it is sanitized before it is stored.
113
+ SubmittedEmpNo string
114
+
115
+ // Before and After are JSON documents (changed fields only). Secrets are
116
+ // redacted by key name before they are stored.
117
+ Before json.RawMessage
118
+ After json.RawMessage
119
+ }
@@ -0,0 +1,270 @@
1
+ //go:build integration
2
+
3
+ package audit_test
4
+
5
+ import (
6
+ "context"
7
+ "database/sql"
8
+ "errors"
9
+ "net/http"
10
+ "net/http/httptest"
11
+ "net/netip"
12
+ "strings"
13
+ "testing"
14
+
15
+ "github.com/google/uuid"
16
+
17
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/audit"
18
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/shared/db"
19
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/shared/dbtest"
20
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/shared/security"
21
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/shared/web"
22
+ )
23
+
24
+ type row struct {
25
+ RequestID, ActorEmpNo, TargetType, TargetID, Method, Route, ErrorCode, IP, UserAgent, Submitted sql.NullString
26
+ ActorRole, Action, Outcome string
27
+ Status sql.NullInt64
28
+ Before, After sql.NullString
29
+ PrevHash, Hash []byte
30
+ OccurredAtSet bool
31
+ }
32
+
33
+ // floor is the highest audit_log.id before the current subtest; rows() sees only newer rows. The
34
+ // table is append-only, so tests never clear it (test/arch scans for that).
35
+ var floor int64
36
+
37
+ func rows(t *testing.T, d *sql.DB) []row {
38
+ t.Helper()
39
+ rs, err := d.Query(`SELECT request_id, actor_emp_no, target_type, target_id, method, route, error_code,
40
+ ip, user_agent, submitted_emp_no, actor_role, action, outcome, status, before_json, after_json,
41
+ prev_hash, hash, occurred_at IS NOT NULL FROM audit_log WHERE id > ? ORDER BY id`, floor)
42
+ if err != nil {
43
+ t.Fatal(err)
44
+ }
45
+ defer rs.Close()
46
+ var out []row
47
+ for rs.Next() {
48
+ var r row
49
+ if err := rs.Scan(&r.RequestID, &r.ActorEmpNo, &r.TargetType, &r.TargetID, &r.Method, &r.Route, &r.ErrorCode,
50
+ &r.IP, &r.UserAgent, &r.Submitted, &r.ActorRole, &r.Action, &r.Outcome, &r.Status, &r.Before, &r.After,
51
+ &r.PrevHash, &r.Hash, &r.OccurredAtSet); err != nil {
52
+ t.Fatal(err)
53
+ }
54
+ out = append(out, r)
55
+ }
56
+ if err := rs.Err(); err != nil {
57
+ t.Fatal(err)
58
+ }
59
+ return out
60
+ }
61
+
62
+ func reset(t *testing.T, d *sql.DB) {
63
+ t.Helper()
64
+ if err := d.QueryRow(`SELECT COALESCE(MAX(id), 0) FROM audit_log`).Scan(&floor); err != nil {
65
+ t.Fatal(err)
66
+ }
67
+ }
68
+
69
+ func TestAudit(t *testing.T) {
70
+ d := dbtest.New(t)
71
+ svc := audit.New(d, nil)
72
+ reqID := uuid.NewString()
73
+
74
+ // Request ID is stored by web.RequestID middleware; reproduce it through the real middleware.
75
+ ctxWithReq := func(principal *security.Principal) context.Context {
76
+ var got context.Context
77
+ h := web.RequestID(handlerFunc(func(ctx context.Context) { got = ctx }))
78
+ h.ServeHTTP(httptest.NewRecorder(), newReq(reqID))
79
+ if principal != nil {
80
+ return security.WithPrincipal(got, *principal)
81
+ }
82
+ return got
83
+ }
84
+
85
+ t.Run("track A is written in the caller's transaction and carries every field", func(t *testing.T) {
86
+ reset(t, d)
87
+ ctx := ctxWithReq(&security.Principal{UserID: uuid.New(), EmpNo: "E0001", Role: security.RoleUser})
88
+ target := uuid.New()
89
+ out, err := db.InTx(ctx, d, func(ctx context.Context, tx *sql.Tx) error {
90
+ return svc.Record(ctx, tx, audit.Event{
91
+ Action: audit.IdeaUpdate, Outcome: audit.Success,
92
+ TargetType: audit.TargetIdea, TargetID: target,
93
+ Method: "PATCH", Route: "/ideas/{id}", Status: 200,
94
+ IP: netip.MustParseAddr("203.0.113.9"), UserAgent: "ua",
95
+ Before: []byte(`{"name":"a"}`), After: []byte(`{"name":"b","password":"p"}`),
96
+ })
97
+ })
98
+ if err != nil || out != db.Committed {
99
+ t.Fatalf("InTx = %v, %v", out, err)
100
+ }
101
+ got := rows(t, d)
102
+ if len(got) != 1 {
103
+ t.Fatalf("rows = %d, want 1", len(got))
104
+ }
105
+ r := got[0]
106
+ if r.RequestID.String != reqID || r.ActorEmpNo.String != "E0001" || r.ActorRole != "USER" ||
107
+ r.Action != "IDEA_UPDATE" || r.Outcome != "SUCCESS" || r.TargetType.String != "IDEA" ||
108
+ r.TargetID.String != target.String() || r.Method.String != "PATCH" || r.Route.String != "/ideas/{id}" ||
109
+ r.Status.Int64 != 200 || r.UserAgent.String != "ua" || !r.OccurredAtSet {
110
+ t.Errorf("row = %+v", r)
111
+ }
112
+ if r.IP.String != "::ffff:203.0.113.9" {
113
+ t.Errorf("ip = %q (IPv4 is stored as INET6 mapped)", r.IP.String)
114
+ }
115
+ if r.Before.String != `{"name": "a"}` && r.Before.String != `{"name":"a"}` {
116
+ t.Errorf("before = %q", r.Before.String)
117
+ }
118
+ if strings.Contains(r.After.String, "\"p\"") || !strings.Contains(r.After.String, "[REDACTED]") {
119
+ t.Errorf("password not redacted: %q", r.After.String)
120
+ }
121
+ if r.PrevHash != nil || r.Hash != nil {
122
+ t.Error("hash-chain columns must stay NULL")
123
+ }
124
+ })
125
+
126
+ t.Run("track A rolls back with the transaction", func(t *testing.T) {
127
+ reset(t, d)
128
+ boom := errors.New("business failure")
129
+ out, err := db.InTx(context.Background(), d, func(ctx context.Context, tx *sql.Tx) error {
130
+ if err := svc.Record(ctx, tx, audit.Event{Action: audit.AuthLogout, Outcome: audit.Success}); err != nil {
131
+ return err
132
+ }
133
+ return boom
134
+ })
135
+ if !errors.Is(err, boom) || out != db.RolledBack {
136
+ t.Fatalf("InTx = %v, %v", out, err)
137
+ }
138
+ if n := len(rows(t, d)); n != 0 {
139
+ t.Errorf("rows = %d, want 0 (rolled back with the business write)", n)
140
+ }
141
+ })
142
+
143
+ t.Run("track A failure fails the operation", func(t *testing.T) {
144
+ reset(t, d)
145
+ out, err := db.InTx(context.Background(), d, func(ctx context.Context, tx *sql.Tx) error {
146
+ return svc.Record(ctx, tx, audit.Event{Action: "BOGUS", Outcome: audit.Success})
147
+ })
148
+ if err == nil || out != db.RolledBack {
149
+ t.Errorf("InTx = %v, %v; want RolledBack and an error", out, err)
150
+ }
151
+ })
152
+
153
+ t.Run("track B persists although the business transaction rolled back", func(t *testing.T) {
154
+ reset(t, d)
155
+ ctx := ctxWithReq(nil)
156
+ _, err := db.InTx(ctx, d, func(ctx context.Context, tx *sql.Tx) error {
157
+ svc.RecordRejected(ctx, audit.Event{
158
+ Action: audit.IdeaDelete, Outcome: audit.Denied, TargetType: audit.TargetIdea,
159
+ TargetID: uuid.New(), Method: "DELETE", Route: "/ideas/{id}", Status: 404, ErrorCode: "NOT_FOUND",
160
+ })
161
+ return errors.New("rejected")
162
+ })
163
+ if err == nil {
164
+ t.Fatal("want error")
165
+ }
166
+ got := rows(t, d)
167
+ if len(got) != 1 || got[0].Outcome != "DENIED" || got[0].ActorRole != "ANONYMOUS" ||
168
+ got[0].ActorEmpNo.Valid || got[0].ErrorCode.String != "NOT_FOUND" || got[0].RequestID.String != reqID {
169
+ t.Errorf("rows = %+v", got)
170
+ }
171
+ })
172
+
173
+ t.Run("track B survives a cancelled request context", func(t *testing.T) {
174
+ reset(t, d)
175
+ ctx, cancel := context.WithCancel(context.Background())
176
+ cancel()
177
+ svc.RecordRejected(ctx, audit.Event{Action: audit.AuthLogin, Outcome: audit.Failed, SubmittedEmpNo: "E0009"})
178
+ if got := rows(t, d); len(got) != 1 || got[0].Submitted.String != "E0009" {
179
+ t.Errorf("rows = %+v", got)
180
+ }
181
+ })
182
+
183
+ t.Run("track B failure is counted, not returned", func(t *testing.T) {
184
+ reset(t, d)
185
+ before := svc.RejectedFailures()
186
+ svc.RecordRejected(context.Background(), audit.Event{Action: "BOGUS", Outcome: audit.Denied})
187
+ if svc.RejectedFailures() != before+1 {
188
+ t.Error("failure not counted")
189
+ }
190
+ if n := len(rows(t, d)); n != 0 {
191
+ t.Errorf("rows = %d", n)
192
+ }
193
+ })
194
+
195
+ t.Run("hostile input is sanitized and truncated, never fatal", func(t *testing.T) {
196
+ reset(t, d)
197
+ svc.RecordRejected(context.Background(), audit.Event{
198
+ Action: audit.AuthLogin, Outcome: audit.Failed,
199
+ UserAgent: strings.Repeat("創", 1000) + "\r\nforged",
200
+ SubmittedEmpNo: "E1\x00\n" + strings.Repeat("9", 500),
201
+ Route: strings.Repeat("r", 1000), ErrorCode: strings.Repeat("c", 1000),
202
+ })
203
+ got := rows(t, d)
204
+ if len(got) != 1 {
205
+ t.Fatalf("rows = %d, want 1", len(got))
206
+ }
207
+ r := got[0]
208
+ if n := len([]rune(r.UserAgent.String)); n != 256 {
209
+ t.Errorf("user agent = %d code points, want 256", n)
210
+ }
211
+ if strings.ContainsAny(r.Submitted.String, "\x00\n") || len(r.Submitted.String) != 64 {
212
+ t.Errorf("submitted = %q", r.Submitted.String)
213
+ }
214
+ if len(r.Route.String) != 200 || len(r.ErrorCode.String) != 64 {
215
+ t.Errorf("route/errorCode lengths = %d/%d", len(r.Route.String), len(r.ErrorCode.String))
216
+ }
217
+ })
218
+
219
+ t.Run("actor override wins over the context", func(t *testing.T) {
220
+ reset(t, d)
221
+ ctx := security.WithSystem(context.Background())
222
+ _, err := db.InTx(ctx, d, func(ctx context.Context, tx *sql.Tx) error {
223
+ return svc.Record(ctx, tx, audit.Event{Action: audit.AuthLogin, Outcome: audit.Success,
224
+ ActorRole: security.ActorAdmin, ActorEmpNo: "A0001", TargetType: audit.TargetUser, TargetID: uuid.New()})
225
+ })
226
+ if err != nil {
227
+ t.Fatal(err)
228
+ }
229
+ if r := rows(t, d)[0]; r.ActorRole != "ADMIN" || r.ActorEmpNo.String != "A0001" {
230
+ t.Errorf("row = %+v", r)
231
+ }
232
+ })
233
+
234
+ t.Run("system actor and deadline target without an ID", func(t *testing.T) {
235
+ reset(t, d)
236
+ ctx := security.WithSystem(context.Background())
237
+ _, err := db.InTx(ctx, d, func(ctx context.Context, tx *sql.Tx) error {
238
+ return svc.Record(ctx, tx, audit.Event{Action: audit.AdminSync, Outcome: audit.Success,
239
+ TargetType: audit.TargetDeadline, After: []byte(`null`)})
240
+ })
241
+ if err != nil {
242
+ t.Fatal(err)
243
+ }
244
+ if r := rows(t, d)[0]; r.ActorRole != "SYSTEM" || r.TargetID.Valid || r.After.Valid {
245
+ t.Errorf("row = %+v", r)
246
+ }
247
+ })
248
+
249
+ t.Run("database CHECKs reject values outside the sets", func(t *testing.T) {
250
+ for _, stmt := range []string{
251
+ `INSERT INTO audit_log (actor_role, action, outcome) VALUES ('ROOT', 'X', 'SUCCESS')`,
252
+ `INSERT INTO audit_log (actor_role, action, outcome) VALUES ('USER', 'X', 'MAYBE')`,
253
+ `INSERT INTO audit_log (actor_role, action, outcome, target_type) VALUES ('USER', 'X', 'SUCCESS', 'TEAM')`,
254
+ } {
255
+ if _, err := d.Exec(stmt); err == nil {
256
+ t.Errorf("accepted: %s", stmt)
257
+ }
258
+ }
259
+ })
260
+ }
261
+
262
+ type handlerFunc func(ctx context.Context)
263
+
264
+ func (f handlerFunc) ServeHTTP(_ http.ResponseWriter, r *http.Request) { f(r.Context()) }
265
+
266
+ func newReq(id string) *http.Request {
267
+ r := httptest.NewRequest(http.MethodGet, "/", nil)
268
+ r.Header.Set(web.RequestIDHeader, id)
269
+ return r
270
+ }
@@ -0,0 +1,102 @@
1
+ package audit
2
+
3
+ import (
4
+ "bytes"
5
+ "encoding/json"
6
+ "fmt"
7
+ "strings"
8
+ "unicode"
9
+ "unicode/utf8"
10
+ )
11
+
12
+ // Column limits (V002). User-controlled text is cut to these by code point, so
13
+ // a hostile header can never make the audit insert fail.
14
+ const (
15
+ maxEmpNo = 64
16
+ maxMethod = 8
17
+ maxRoute = 200
18
+ maxErrorCode = 64
19
+ maxUserAgent = 256 // PRD §6.11
20
+
21
+ // maxJSONBytes caps before_json / after_json; a bigger document is replaced
22
+ // by a marker, never cut in the middle (that would be invalid JSON).
23
+ maxJSONBytes = 16 << 10
24
+ )
25
+
26
+ // redacted replaces the value of a secret-looking key.
27
+ const redacted = "[REDACTED]"
28
+
29
+ // secretKeys are JSON keys whose values are never stored (PRD §6.11: no
30
+ // passwords, tokens, session IDs, CSRF values or file contents). Compared after
31
+ // lower-casing and removing "_" and "-".
32
+ var secretKeys = map[string]bool{
33
+ "password": true, "passwd": true, "newpassword": true, "oldpassword": true, "currentpassword": true,
34
+ "token": true, "csrf": true, "csrftoken": true, "xcsrftoken": true,
35
+ "session": true, "sessionid": true, "cookie": true, "setcookie": true,
36
+ "authorization": true, "secret": true, "content": true, "filecontent": true,
37
+ }
38
+
39
+ // clean makes s safe to store and cuts it to max code points: invalid UTF-8
40
+ // and control characters (including NUL and line breaks, which would forge log
41
+ // lines) are dropped, and the result is trimmed.
42
+ func clean(s string, max int) string {
43
+ s = strings.ToValidUTF8(s, "")
44
+ s = strings.Map(func(r rune) rune {
45
+ if unicode.IsControl(r) || r == '
' || r == '
' {
46
+ return -1
47
+ }
48
+ return r
49
+ }, s)
50
+ s = strings.TrimSpace(s)
51
+ if utf8.RuneCountInString(s) > max {
52
+ s = string([]rune(s)[:max])
53
+ }
54
+ return s
55
+ }
56
+
57
+ // cleanJSON validates raw, redacts secret keys and returns the compact
58
+ // document; nil means "store NULL" (empty input or JSON null).
59
+ func cleanJSON(raw json.RawMessage) ([]byte, error) {
60
+ if len(bytes.TrimSpace(raw)) == 0 {
61
+ return nil, nil
62
+ }
63
+ dec := json.NewDecoder(bytes.NewReader(raw))
64
+ dec.UseNumber() // keep numbers exactly as written
65
+ var v any
66
+ if err := dec.Decode(&v); err != nil {
67
+ return nil, fmt.Errorf("audit: invalid JSON: %w", err)
68
+ }
69
+ if dec.More() {
70
+ return nil, fmt.Errorf("audit: trailing data after JSON value")
71
+ }
72
+ if v == nil {
73
+ return nil, nil
74
+ }
75
+ out, err := json.Marshal(redact(v))
76
+ if err != nil {
77
+ return nil, fmt.Errorf("audit: marshal JSON: %w", err)
78
+ }
79
+ if len(out) > maxJSONBytes {
80
+ return json.Marshal(map[string]any{"truncated": true, "bytes": len(out)})
81
+ }
82
+ return out, nil
83
+ }
84
+
85
+ func redact(v any) any {
86
+ switch t := v.(type) {
87
+ case map[string]any:
88
+ for k, val := range t {
89
+ norm := strings.NewReplacer("_", "", "-", "").Replace(strings.ToLower(k))
90
+ if secretKeys[norm] {
91
+ t[k] = redacted
92
+ } else {
93
+ t[k] = redact(val)
94
+ }
95
+ }
96
+ case []any:
97
+ for i, val := range t {
98
+ t[i] = redact(val)
99
+ }
100
+ }
101
+ return v
102
+ }
@@ -0,0 +1,134 @@
1
+ package audit
2
+
3
+ import (
4
+ "context"
5
+ "database/sql"
6
+ "encoding/json"
7
+ "strings"
8
+ "testing"
9
+
10
+ "github.com/google/uuid"
11
+ )
12
+
13
+ func TestClean(t *testing.T) {
14
+ tests := []struct {
15
+ name string
16
+ in string
17
+ max int
18
+ want string
19
+ }{
20
+ {"plain", "E12345", 64, "E12345"},
21
+ {"control chars and newlines dropped", "a\x00b\r\nc\td", 64, "abcd"},
22
+ {"line separators dropped", "a
b
c", 64, "abc"},
23
+ {"invalid utf-8 dropped", "ab\xffcd", 64, "abcd"},
24
+ {"trimmed", " x ", 64, "x"},
25
+ {"cut by code point, not byte", strings.Repeat("創", 10), 4, "創創創創"},
26
+ {"emoji kept whole", "😀😀😀", 2, "😀😀"},
27
+ {"empty", "", 8, ""},
28
+ }
29
+ for _, tc := range tests {
30
+ t.Run(tc.name, func(t *testing.T) {
31
+ if got := clean(tc.in, tc.max); got != tc.want {
32
+ t.Errorf("clean(%q, %d) = %q, want %q", tc.in, tc.max, got, tc.want)
33
+ }
34
+ })
35
+ }
36
+ }
37
+
38
+ func TestCleanJSON(t *testing.T) {
39
+ tests := []struct {
40
+ name string
41
+ in string
42
+ want string // "" = NULL
43
+ wantErr bool
44
+ }{
45
+ {"empty is NULL", ``, "", false},
46
+ {"blank is NULL", " \n", "", false},
47
+ {"null is NULL", `null`, "", false},
48
+ {"compacted", "{ \"a\": 1 }", `{"a":1}`, false},
49
+ {"big number kept exactly", `{"n":12345678901234567890}`, `{"n":12345678901234567890}`, false},
50
+ {"password redacted", `{"password":"hunter2","name":"x"}`, `{"name":"x","password":"[REDACTED]"}`, false},
51
+ {"key spelling ignored", `{"Session_ID":"s","x-csrf-token":"c","Set-Cookie":"k"}`,
52
+ `{"Session_ID":"[REDACTED]","Set-Cookie":"[REDACTED]","x-csrf-token":"[REDACTED]"}`, false},
53
+ {"nested and arrays", `{"a":[{"token":"t","ok":true}]}`, `{"a":[{"ok":true,"token":"[REDACTED]"}]}`, false},
54
+ {"file content redacted", `{"filename":"a.pptx","content":"AAAA"}`, `{"content":"[REDACTED]","filename":"a.pptx"}`, false},
55
+ {"invalid", `{"a":`, "", true},
56
+ {"trailing data", `{"a":1} {"b":2}`, "", true},
57
+ }
58
+ for _, tc := range tests {
59
+ t.Run(tc.name, func(t *testing.T) {
60
+ got, err := cleanJSON(json.RawMessage(tc.in))
61
+ if (err != nil) != tc.wantErr {
62
+ t.Fatalf("err = %v, wantErr %v", err, tc.wantErr)
63
+ }
64
+ if string(got) != tc.want {
65
+ t.Errorf("got %s, want %s", got, tc.want)
66
+ }
67
+ if tc.want == "" && got != nil {
68
+ t.Errorf("want nil slice (NULL), got %q", got)
69
+ }
70
+ })
71
+ }
72
+ }
73
+
74
+ func TestCleanJSONOversizeIsMarker(t *testing.T) {
75
+ big := `{"text":"` + strings.Repeat("x", maxJSONBytes) + `"}`
76
+ got, err := cleanJSON(json.RawMessage(big))
77
+ if err != nil {
78
+ t.Fatal(err)
79
+ }
80
+ var m map[string]any
81
+ if err := json.Unmarshal(got, &m); err != nil {
82
+ t.Fatalf("marker is not valid JSON: %v", err)
83
+ }
84
+ if m["truncated"] != true || len(got) > 100 {
85
+ t.Errorf("marker = %s", got)
86
+ }
87
+ }
88
+
89
+ func TestInsertRejectsBadEventsBeforeTouchingTheDatabase(t *testing.T) {
90
+ ok := Event{Action: AuthLogin, Outcome: Success}
91
+ tests := []struct {
92
+ name string
93
+ mod func(*Event)
94
+ ctx context.Context
95
+ }{
96
+ {"unknown action", func(e *Event) { e.Action = "NOPE" }, nil},
97
+ {"empty action", func(e *Event) { e.Action = "" }, nil},
98
+ {"unknown outcome", func(e *Event) { e.Outcome = "MAYBE" }, nil},
99
+ {"unknown target type", func(e *Event) { e.TargetType = "TEAM" }, nil},
100
+ {"target id without type", func(e *Event) { e.TargetID = uuid.New() }, nil},
101
+ {"unknown actor role", func(e *Event) { e.ActorRole = "ROOT" }, nil},
102
+ {"bad before", func(e *Event) { e.Before = json.RawMessage(`{`) }, nil},
103
+ {"bad after", func(e *Event) { e.After = json.RawMessage(`[`) }, nil},
104
+ }
105
+ for _, tc := range tests {
106
+ t.Run(tc.name, func(t *testing.T) {
107
+ e := ok
108
+ tc.mod(&e)
109
+ ctx := tc.ctx
110
+ if ctx == nil {
111
+ ctx = context.Background()
112
+ }
113
+ // A nil execer would panic if validation did not stop first.
114
+ if err := insert(ctx, nil, e); err == nil {
115
+ t.Error("want error")
116
+ }
117
+ })
118
+ }
119
+ }
120
+
121
+ func TestRecordNeedsTransaction(t *testing.T) {
122
+ var tx *sql.Tx
123
+ if err := New(nil, nil).Record(context.Background(), tx, Event{Action: AuthLogin, Outcome: Success}); err == nil {
124
+ t.Error("want error for nil tx")
125
+ }
126
+ }
127
+
128
+ func TestRecordRejectedRefusesSuccess(t *testing.T) {
129
+ s := New(nil, nil) // a nil DB would panic if it got that far
130
+ s.RecordRejected(context.Background(), Event{Action: AuthLogin, Outcome: Success})
131
+ if s.RejectedFailures() != 1 {
132
+ t.Errorf("failures = %d, want 1", s.RejectedFailures())
133
+ }
134
+ }