@johnnyboy11234/esg-idea-contest 0.3.0 → 0.3.2

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 (138) hide show
  1. package/backend/api/openapi.yaml +119 -0
  2. package/backend/cmd/server/main.go +36 -2
  3. package/backend/db/migration/V005__idea.sql +49 -0
  4. package/backend/db/migration/V006__login_lock.sql +15 -0
  5. package/backend/db/migration/V007__login_attempt.sql +15 -0
  6. package/backend/db/migration/V008__pending_file_deletion.sql +27 -0
  7. package/backend/db/migration/V009__login_throttle_audit.sql +13 -0
  8. package/backend/go.mod +17 -3
  9. package/backend/go.sum +48 -7
  10. package/backend/internal/account/account_test.go +6 -6
  11. package/backend/internal/account/integration_test.go +45 -9
  12. package/backend/internal/account/internal/passwords/NOTICE +36 -0
  13. package/backend/internal/account/internal/passwords/common-passwords.txt +10001 -0
  14. package/backend/internal/account/internal/passwords/passwords.go +37 -0
  15. package/backend/internal/account/internal/passwords/passwords_test.go +35 -0
  16. package/backend/internal/account/keyboard.go +75 -0
  17. package/backend/internal/account/keyboard_test.go +43 -0
  18. package/backend/internal/account/logging_integration_test.go +1 -1
  19. package/backend/internal/account/password.go +131 -0
  20. package/backend/internal/account/sequence.go +54 -0
  21. package/backend/internal/account/sequence_test.go +38 -0
  22. package/backend/internal/account/validate.go +4 -21
  23. package/backend/internal/api/api.gen.go +262 -0
  24. package/backend/internal/auth/internal/store/login_attempt.go +53 -0
  25. package/backend/internal/auth/internal/store/login_lock.go +77 -0
  26. package/backend/internal/auth/internal/store/login_throttle_audit.go +42 -0
  27. package/backend/internal/auth/internal/store/session.go +15 -7
  28. package/backend/internal/auth/login.go +16 -4
  29. package/backend/internal/auth/login_integration_test.go +14 -1
  30. package/backend/internal/auth/login_ip_integration_test.go +153 -0
  31. package/backend/internal/auth/login_lock_integration_test.go +205 -0
  32. package/backend/internal/auth/login_test.go +15 -0
  33. package/backend/internal/auth/login_throttle_audit_integration_test.go +88 -0
  34. package/backend/internal/auth/purge.go +93 -0
  35. package/backend/internal/auth/purge_integration_test.go +220 -0
  36. package/backend/internal/auth/purge_test.go +51 -0
  37. package/backend/internal/auth/session.go +0 -6
  38. package/backend/internal/auth/session_integration_test.go +3 -3
  39. package/backend/internal/auth/throttle.go +122 -0
  40. package/backend/internal/deadline/gate.go +80 -0
  41. package/backend/internal/deadline/gate_integration_test.go +67 -0
  42. package/backend/internal/deadline/gate_test.go +61 -0
  43. package/backend/internal/idea/create.go +356 -0
  44. package/backend/internal/idea/create_integration_test.go +680 -0
  45. package/backend/internal/idea/download.go +193 -0
  46. package/backend/internal/idea/download_integration_test.go +311 -0
  47. package/backend/internal/idea/download_test.go +25 -0
  48. package/backend/internal/idea/handler.go +151 -0
  49. package/backend/internal/idea/handler_integration_test.go +107 -0
  50. package/backend/internal/idea/integration_test.go +149 -0
  51. package/backend/internal/idea/internal/store/list.go +146 -0
  52. package/backend/internal/idea/internal/store/list_test.go +33 -0
  53. package/backend/internal/idea/internal/store/store.go +166 -0
  54. package/backend/internal/idea/list.go +166 -0
  55. package/backend/internal/idea/list_integration_test.go +252 -0
  56. package/backend/internal/idea/list_test.go +96 -0
  57. package/backend/internal/idea/service.go +54 -0
  58. package/backend/internal/server/api.go +22 -0
  59. package/backend/internal/server/binding.go +4 -0
  60. package/backend/internal/server/public.go +1 -0
  61. package/backend/internal/server/server.go +20 -1
  62. package/backend/internal/server/slides.go +21 -0
  63. package/backend/internal/server/slides_test.go +82 -0
  64. package/backend/internal/shared/config/config.go +44 -1
  65. package/backend/internal/shared/config/config_test.go +21 -13
  66. package/backend/internal/shared/db/intx_integration_test.go +179 -0
  67. package/backend/internal/shared/dbtest/dbtest.go +21 -3
  68. package/backend/internal/shared/dbtest/faults.go +162 -0
  69. package/backend/internal/shared/job/job.go +54 -0
  70. package/backend/internal/shared/job/job_test.go +105 -0
  71. package/backend/internal/shared/web/multipart.go +175 -0
  72. package/backend/internal/shared/web/multipart_test.go +169 -0
  73. package/backend/internal/shared/web/problem.go +1 -0
  74. package/backend/internal/shared/web/request.go +42 -1
  75. package/backend/internal/shared/web/transport.go +4 -3
  76. package/backend/internal/shared/web/web_test.go +36 -0
  77. package/backend/internal/storage/expectedfilename.go +43 -0
  78. package/backend/internal/storage/expectedfilename_test.go +16 -0
  79. package/backend/internal/storage/internal/store/pending.go +41 -0
  80. package/backend/internal/storage/minio.go +139 -0
  81. package/backend/internal/storage/minio_integration_test.go +87 -0
  82. package/backend/internal/storage/minio_test.go +84 -0
  83. package/backend/internal/storage/pending.go +144 -0
  84. package/backend/internal/storage/pending_integration_test.go +324 -0
  85. package/backend/internal/storage/pending_test.go +23 -0
  86. package/backend/internal/storage/validator.go +100 -0
  87. package/backend/internal/storage/validator_test.go +95 -0
  88. package/backend/test/arch/arch_test.go +5 -1
  89. package/backend/test/contract/expected_filename_test.go +60 -0
  90. package/backend/test/contract/password_test.go +46 -0
  91. package/backend/test/matrix/load_test.go +12 -0
  92. package/backend/test/matrix/matrix_integration_test.go +92 -5
  93. package/backend/test/migration/pending_file_deletion_test.go +46 -0
  94. package/docs/API/api-endpoints.md +1 -1
  95. package/docs/API/error-codes.md +1 -1
  96. package/docs/Architecture/Architecture.md +1 -1
  97. package/docs/Architecture/Backend.md +2 -1
  98. package/frontend/Dockerfile +2 -0
  99. package/frontend/package-lock.json +1 -0
  100. package/frontend/package.json +1 -0
  101. package/frontend/src/app/App.tsx +11 -2
  102. package/frontend/src/app/pages/ideaCreate.test.tsx +308 -0
  103. package/frontend/src/app/pages/ideas.tsx +23 -9
  104. package/frontend/src/app/pages/login.test.tsx +38 -1
  105. package/frontend/src/app/pages/register.test.tsx +32 -0
  106. package/frontend/src/features/auth/IdleWarning.test.tsx +82 -0
  107. package/frontend/src/features/auth/IdleWarning.tsx +66 -0
  108. package/frontend/src/features/auth/LoginForm.tsx +21 -3
  109. package/frontend/src/features/auth/PasswordChecklist.test.tsx +73 -0
  110. package/frontend/src/features/auth/PasswordChecklist.tsx +96 -0
  111. package/frontend/src/features/auth/RegisterForm.tsx +11 -2
  112. package/frontend/src/features/auth/common-passwords.ts +31 -0
  113. package/frontend/src/features/auth/password-policy.test.ts +64 -0
  114. package/frontend/src/features/auth/password-policy.ts +168 -0
  115. package/frontend/src/features/auth/useCountdown.test.tsx +33 -0
  116. package/frontend/src/features/auth/useCountdown.ts +28 -0
  117. package/frontend/src/features/idea/CreateIdeaForm.tsx +279 -0
  118. package/frontend/src/features/idea/create.ts +123 -0
  119. package/frontend/src/shared/api/activity.ts +20 -0
  120. package/frontend/src/shared/api/http.ts +2 -0
  121. package/frontend/src/shared/api/index.ts +1 -0
  122. package/frontend/src/shared/api/types.generated.ts +319 -1
  123. package/frontend/src/shared/i18n/locales/en.ts +46 -4
  124. package/frontend/src/shared/i18n/locales/zh-TW.ts +46 -4
  125. package/frontend/src/shared/slides/index.ts +74 -0
  126. package/frontend/src/shared/slides/slides.test.ts +51 -0
  127. package/frontend/src/shared/ui/copy-button.tsx +52 -0
  128. package/frontend/src/shared/ui/native-select.tsx +27 -0
  129. package/frontend/src/shared/ui/textarea.tsx +19 -0
  130. package/frontend/src/test-setup.ts +37 -2
  131. package/frontend/vite.config.ts +3 -1
  132. package/package.json +1 -1
  133. package/testdata/expected-filename.json +93 -0
  134. package/testdata/password.json +582 -0
  135. package/testdata/permission-matrix.json +71 -0
  136. package/testdata/schema/expected-filename.schema.json +32 -0
  137. package/testdata/schema/password.schema.json +59 -0
  138. package/testdata/schema/permission-matrix.schema.json +22 -1
@@ -0,0 +1,220 @@
1
+ //go:build integration
2
+
3
+ package auth_test
4
+
5
+ import (
6
+ "bytes"
7
+ "context"
8
+ "database/sql"
9
+ "errors"
10
+ "fmt"
11
+ "log/slog"
12
+ "strings"
13
+ "sync"
14
+ "testing"
15
+ "time"
16
+
17
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/auth"
18
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/shared/dbtest"
19
+ )
20
+
21
+ // purgeFixture seeds every purged table with rows that must stay and rows that
22
+ // must go. Each "dead" group is larger than the batch size so the drain loop
23
+ // runs several DELETEs.
24
+ type purgeFixture struct {
25
+ liveSessions []string
26
+ deadSessions int
27
+ }
28
+
29
+ func seedPurge(t *testing.T, db *sql.DB, s *auth.Sessions) purgeFixture {
30
+ t.Helper()
31
+ ctx := context.Background()
32
+ exec := func(q string, args ...any) {
33
+ t.Helper()
34
+ if _, err := db.Exec(q, args...); err != nil {
35
+ t.Fatalf("%s: %v", q, err)
36
+ }
37
+ }
38
+ var f purgeFixture
39
+ u := addUser(t, db, "200001", "USER", true)
40
+ for i := range 3 {
41
+ id, err := s.Create(ctx, u)
42
+ if err != nil {
43
+ t.Fatal(err)
44
+ }
45
+ if i == 0 { // inside both timeouts, but close to both
46
+ age(t, db, id, 7*time.Hour+50*time.Minute, 29*time.Minute)
47
+ }
48
+ f.liveSessions = append(f.liveSessions, id)
49
+ }
50
+ for i := range 7 {
51
+ id, err := s.Create(ctx, u)
52
+ if err != nil {
53
+ t.Fatal(err)
54
+ }
55
+ if i%2 == 0 {
56
+ age(t, db, id, time.Hour, 31*time.Minute) // idle timeout
57
+ } else {
58
+ age(t, db, id, 8*time.Hour+time.Minute, 0) // absolute timeout
59
+ }
60
+ f.deadSessions++
61
+ }
62
+
63
+ lock := func(key string, lastFailAgo time.Duration, lockedUntil string) {
64
+ exec(`INSERT INTO login_lock (lock_key, fail_count, last_fail_at, locked_until)
65
+ VALUES (?, 5, NOW(6) - INTERVAL ? SECOND, `+lockedUntil+`)`, key, int(lastFailAgo.Seconds()))
66
+ }
67
+ lock("keep-recent", time.Minute, "NULL") // counting window still open
68
+ lock("keep-locked", 20*time.Minute, "NOW(6) + INTERVAL 5 MINUTE") // lock active
69
+ lock("keep-just-unlocked", time.Minute, "NOW(6) - INTERVAL 1 SECOND") // window still open
70
+ for i := range 5 {
71
+ lock(fmt.Sprintf("dead-stale-%d", i), 16*time.Minute, "NULL")
72
+ lock(fmt.Sprintf("dead-unlocked-%d", i), 31*time.Minute, "NOW(6) - INTERVAL 1 MINUTE")
73
+ }
74
+
75
+ for range 4 {
76
+ exec(`INSERT INTO login_attempt (ip, attempted_at) VALUES ('192.0.2.1', NOW(6) - INTERVAL 14 MINUTE)`)
77
+ }
78
+ for range 9 {
79
+ exec(`INSERT INTO login_attempt (ip, attempted_at) VALUES ('192.0.2.2', NOW(6) - INTERVAL 901 SECOND)`)
80
+ }
81
+
82
+ exec(`INSERT INTO login_throttle_audit VALUES ('ip', 'keep', NOW(6) + INTERVAL 1 MINUTE)`)
83
+ for i := range 5 {
84
+ exec(`INSERT INTO login_throttle_audit VALUES ('key', ?, NOW(6) - INTERVAL 1 SECOND)`, fmt.Sprintf("dead-%d", i))
85
+ }
86
+ return f
87
+ }
88
+
89
+ func count(t *testing.T, db *sql.DB, q string) (n int) {
90
+ t.Helper()
91
+ if err := db.QueryRow(q).Scan(&n); err != nil {
92
+ t.Fatalf("%s: %v", q, err)
93
+ }
94
+ return n
95
+ }
96
+
97
+ func assertPurged(t *testing.T, db *sql.DB, s *auth.Sessions, f purgeFixture) {
98
+ t.Helper()
99
+ if n := count(t, db, `SELECT COUNT(*) FROM session`); n != len(f.liveSessions) {
100
+ t.Errorf("sessions = %d, want %d", n, len(f.liveSessions))
101
+ }
102
+ for _, id := range f.liveSessions {
103
+ if _, ok, err := s.Validate(context.Background(), id); err != nil || !ok {
104
+ t.Errorf("live session lost: %v", err)
105
+ }
106
+ }
107
+ rows, err := db.Query(`SELECT lock_key FROM login_lock ORDER BY lock_key`)
108
+ if err != nil {
109
+ t.Fatal(err)
110
+ }
111
+ defer func() { _ = rows.Close() }()
112
+ var keys []string
113
+ for rows.Next() {
114
+ var k string
115
+ if err := rows.Scan(&k); err != nil {
116
+ t.Fatal(err)
117
+ }
118
+ keys = append(keys, k)
119
+ }
120
+ if got := strings.Join(keys, ","); got != "keep-just-unlocked,keep-locked,keep-recent" {
121
+ t.Errorf("login_lock keys = %s", got)
122
+ }
123
+ if n := count(t, db, `SELECT COUNT(*) FROM login_attempt WHERE ip = '192.0.2.1'`); n != 4 {
124
+ t.Errorf("attempts inside window = %d, want 4", n)
125
+ }
126
+ if n := count(t, db, `SELECT COUNT(*) FROM login_attempt WHERE ip = '192.0.2.2'`); n != 0 {
127
+ t.Errorf("attempts outside window = %d, want 0", n)
128
+ }
129
+ if n := count(t, db, `SELECT COUNT(*) FROM login_throttle_audit WHERE subject = 'keep'`); n != 1 {
130
+ t.Errorf("active throttle claim lost")
131
+ }
132
+ if n := count(t, db, `SELECT COUNT(*) FROM login_throttle_audit`); n != 1 {
133
+ t.Errorf("throttle claims = %d, want 1", n)
134
+ }
135
+ }
136
+
137
+ func TestPurgeDeletesDeadRowsOnly(t *testing.T) {
138
+ db := dbtest.New(t)
139
+ s := auth.New(db)
140
+ f := seedPurge(t, db, s)
141
+ var buf bytes.Buffer
142
+ p := auth.Purger{DB: db, Log: slog.New(slog.NewTextHandler(&buf, nil)), Batch: 2}
143
+ if err := p.Purge(context.Background()); err != nil {
144
+ t.Fatal(err)
145
+ }
146
+ assertPurged(t, db, s, f)
147
+ for _, want := range []string{
148
+ "table=session deleted=7", "table=login_lock deleted=10",
149
+ "table=login_attempt deleted=9", "table=login_throttle_audit deleted=5",
150
+ } {
151
+ if !strings.Contains(buf.String(), want) {
152
+ t.Errorf("log lacks %q: %s", want, buf.String())
153
+ }
154
+ }
155
+
156
+ // Idempotent: a second pass finds nothing and logs nothing.
157
+ buf.Reset()
158
+ if err := p.Purge(context.Background()); err != nil {
159
+ t.Fatal(err)
160
+ }
161
+ assertPurged(t, db, s, f)
162
+ if buf.Len() != 0 {
163
+ t.Errorf("second pass logged: %s", buf.String())
164
+ }
165
+ }
166
+
167
+ // Every replica runs the purge with no leader. Several runners on the same
168
+ // rows must neither fail nor delete a row that is still live.
169
+ func TestPurgeConcurrentRunnersDoNotConflict(t *testing.T) {
170
+ db := dbtest.New(t)
171
+ s := auth.New(db)
172
+ f := seedPurge(t, db, s)
173
+ const runners = 4
174
+ var wg sync.WaitGroup
175
+ errs := make(chan error, runners)
176
+ start := make(chan struct{})
177
+ for range runners {
178
+ wg.Go(func() {
179
+ <-start
180
+ p := auth.Purger{DB: db, Log: slog.New(slog.DiscardHandler), Batch: 1}
181
+ errs <- p.Purge(context.Background())
182
+ })
183
+ }
184
+ close(start)
185
+ wg.Wait()
186
+ close(errs)
187
+ for err := range errs {
188
+ if err != nil {
189
+ t.Errorf("runner failed: %v", err)
190
+ }
191
+ }
192
+ assertPurged(t, db, s, f)
193
+ }
194
+
195
+ func TestPurgeStopsOnCancel(t *testing.T) {
196
+ db := dbtest.New(t)
197
+ ctx, cancel := context.WithCancel(context.Background())
198
+ cancel()
199
+ if err := (auth.Purger{DB: db, Log: slog.New(slog.DiscardHandler)}).Purge(ctx); !errors.Is(err, context.Canceled) {
200
+ t.Fatalf("err = %v, want context.Canceled", err)
201
+ }
202
+ }
203
+
204
+ // A database error on one table is returned, but the other tables still get
205
+ // their turn and each failure is logged.
206
+ func TestPurgeReportsDatabaseErrors(t *testing.T) {
207
+ db, err := sql.Open("mysql", dbtest.DSN("127.0.0.1:1"))
208
+ if err != nil {
209
+ t.Fatal(err)
210
+ }
211
+ _ = db.Close()
212
+ var buf bytes.Buffer
213
+ err = (auth.Purger{DB: db, Log: slog.New(slog.NewTextHandler(&buf, nil))}).Purge(context.Background())
214
+ if err == nil || !strings.Contains(err.Error(), "purge sessions") {
215
+ t.Fatalf("err = %v", err)
216
+ }
217
+ if n := strings.Count(buf.String(), "purge failed"); n != 4 {
218
+ t.Errorf("logged %d failures, want 4: %s", n, buf.String())
219
+ }
220
+ }
@@ -0,0 +1,51 @@
1
+ package auth
2
+
3
+ import (
4
+ "context"
5
+ "database/sql"
6
+ "errors"
7
+ "testing"
8
+ )
9
+
10
+ func TestDrainRepeatsUntilShortBatch(t *testing.T) {
11
+ batches := []int64{3, 3, 1, 99}
12
+ var calls int
13
+ n, err := drain(context.Background(), nil, 3, func(_ context.Context, _ *sql.DB, limit int) (int64, error) {
14
+ if limit != 3 {
15
+ t.Fatalf("limit = %d", limit)
16
+ }
17
+ calls++
18
+ return batches[calls-1], nil
19
+ })
20
+ if err != nil || n != 7 || calls != 3 {
21
+ t.Fatalf("drain = %d, %v after %d calls; want 7 after 3", n, err, calls)
22
+ }
23
+ }
24
+
25
+ func TestDrainStopsOnError(t *testing.T) {
26
+ boom := errors.New("boom")
27
+ var calls int
28
+ n, err := drain(context.Background(), nil, 2, func(context.Context, *sql.DB, int) (int64, error) {
29
+ calls++
30
+ if calls == 2 {
31
+ return 0, boom
32
+ }
33
+ return 2, nil
34
+ })
35
+ if !errors.Is(err, boom) || n != 2 || calls != 2 {
36
+ t.Fatalf("drain = %d, %v after %d calls", n, err, calls)
37
+ }
38
+ }
39
+
40
+ func TestDrainStopsOnCancel(t *testing.T) {
41
+ ctx, cancel := context.WithCancel(context.Background())
42
+ var calls int
43
+ n, err := drain(ctx, nil, 1, func(context.Context, *sql.DB, int) (int64, error) {
44
+ calls++
45
+ cancel()
46
+ return 1, nil
47
+ })
48
+ if !errors.Is(err, context.Canceled) || n != 1 || calls != 1 {
49
+ t.Fatalf("drain = %d, %v after %d calls", n, err, calls)
50
+ }
51
+ }
@@ -106,12 +106,6 @@ func (s *Sessions) RevokeAll(ctx context.Context, userID uuid.UUID) error {
106
106
  return store.DeleteByUser(ctx, s.DB, userID)
107
107
  }
108
108
 
109
- // Purge deletes expired sessions and returns how many; a background ticker
110
- // calls it. Validation never depends on it: expired rows already fail Touch.
111
- func (s *Sessions) Purge(ctx context.Context) (int64, error) {
112
- return store.DeleteExpired(ctx, s.DB, limits)
113
- }
114
-
115
109
  // Binding returns SHA-256 of the session ID, the value the CSRF token is bound
116
110
  // to (D40). ok is false for a malformed ID.
117
111
  func Binding(id string) (b []byte, ok bool) { return parse(id) }
@@ -7,6 +7,7 @@ import (
7
7
  "crypto/sha256"
8
8
  "database/sql"
9
9
  "encoding/base64"
10
+ "log/slog"
10
11
  "testing"
11
12
  "time"
12
13
 
@@ -216,9 +217,8 @@ func TestSessions(t *testing.T) {
216
217
  live, idle, old := create(t, u), create(t, u), create(t, u)
217
218
  age(t, db, idle, time.Hour, time.Hour)
218
219
  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)
220
+ if err := (auth.Purger{DB: db, Log: slog.New(slog.DiscardHandler)}).Purge(ctx); err != nil {
221
+ t.Fatal(err)
222
222
  }
223
223
  if !valid(t, live) {
224
224
  t.Fatal("live session purged")
@@ -0,0 +1,122 @@
1
+ package auth
2
+
3
+ import (
4
+ "context"
5
+ "database/sql"
6
+ "net/netip"
7
+ "strings"
8
+ "time"
9
+ "unicode"
10
+
11
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/auth/internal/store"
12
+ )
13
+
14
+ const (
15
+ loginMaxFailures = 5
16
+ loginWindow = 15 * time.Minute
17
+ loginLockFor = 15 * time.Minute
18
+ loginKeyMax = 32
19
+
20
+ ipMaxFailures = 30
21
+ ipWindow = 15 * time.Minute
22
+ )
23
+
24
+ // DBThrottle is the Throttle over the login_lock table: 5 consecutive
25
+ // failures, each within 15 minutes of the previous one, lock the submitted
26
+ // employee-ID string for a fixed 15 minutes (PRD §6.5, OI-12). The 5th failure
27
+ // still answers 401; the lock shows from the 6th attempt. The counters live in
28
+ // MariaDB so all replicas share them, and every time is database time.
29
+ //
30
+ // It also applies the per-IP limit: 30 failures from one address inside a
31
+ // sliding 15-minute window answer 429 until the 30th newest failure leaves the
32
+ // window (login_attempt). A throttled attempt is neither counted for the key
33
+ // nor for the IP. An invalid (zero) ip skips the IP window.
34
+ type DBThrottle struct{ DB *sql.DB }
35
+
36
+ var lockPolicy = store.LockPolicy{
37
+ MaxFailures: loginMaxFailures,
38
+ WindowSeconds: int(loginWindow / time.Second),
39
+ LockSeconds: int(loginLockFor / time.Second),
40
+ }
41
+
42
+ var ipPolicy = store.IPPolicy{MaxFailures: ipMaxFailures, WindowSeconds: int(ipWindow / time.Second)}
43
+
44
+ // LockKey normalises the submitted employee-ID string: control characters are
45
+ // removed and the rest is cut to 32 characters.
46
+ func LockKey(s string) string {
47
+ s = strings.Map(func(r rune) rune {
48
+ if unicode.IsControl(r) {
49
+ return -1
50
+ }
51
+ return r
52
+ }, s)
53
+ if r := []rune(s); len(r) > loginKeyMax {
54
+ s = string(r[:loginKeyMax])
55
+ }
56
+ return s
57
+ }
58
+
59
+ // Check implements Throttle. Both limits apply; the longer wait is returned.
60
+ func (t DBThrottle) Check(ctx context.Context, key string, ip netip.Addr) (time.Duration, error) {
61
+ keyWait, err := store.LockRemaining(ctx, t.DB, LockKey(key))
62
+ if err != nil {
63
+ return 0, err
64
+ }
65
+ if !ip.IsValid() {
66
+ return keyWait, nil
67
+ }
68
+ ipWait, err := store.IPRetryAfter(ctx, t.DB, ipString(ip), ipPolicy)
69
+ if err != nil {
70
+ return 0, err
71
+ }
72
+ return max(keyWait, ipWait), nil
73
+ }
74
+
75
+ // AuditOnce implements Throttle: the first throttled attempt of a lock or IP
76
+ // window claims the audit row; later ones in the same window do not. When both
77
+ // limits apply, the attempt is audited if either window is new.
78
+ func (t DBThrottle) AuditOnce(ctx context.Context, key string, ip netip.Addr) (bool, error) {
79
+ var first bool
80
+ if wait, err := store.LockRemaining(ctx, t.DB, LockKey(key)); err != nil {
81
+ return false, err
82
+ } else if wait > 0 {
83
+ won, err := store.ClaimThrottleAudit(ctx, t.DB, "key", LockKey(key), wait)
84
+ if err != nil {
85
+ return false, err
86
+ }
87
+ first = first || won
88
+ }
89
+ if ip.IsValid() {
90
+ wait, err := store.IPRetryAfter(ctx, t.DB, ipString(ip), ipPolicy)
91
+ if err != nil {
92
+ return false, err
93
+ }
94
+ if wait > 0 {
95
+ won, err := store.ClaimThrottleAudit(ctx, t.DB, "ip", ipString(ip), wait)
96
+ if err != nil {
97
+ return false, err
98
+ }
99
+ first = first || won
100
+ }
101
+ }
102
+ return first, nil
103
+ }
104
+
105
+ // Fail implements Throttle.
106
+ func (t DBThrottle) Fail(ctx context.Context, key string, ip netip.Addr) error {
107
+ if err := store.RecordFailure(ctx, t.DB, LockKey(key), lockPolicy); err != nil {
108
+ return err
109
+ }
110
+ if !ip.IsValid() {
111
+ return nil
112
+ }
113
+ return store.RecordIPFailure(ctx, t.DB, ipString(ip))
114
+ }
115
+
116
+ // Succeed implements Throttle. The IP window is not cleared: its failures stay
117
+ // until they leave the window.
118
+ func (t DBThrottle) Succeed(ctx context.Context, key string) error {
119
+ return store.ClearFailures(ctx, t.DB, LockKey(key))
120
+ }
121
+
122
+ func ipString(ip netip.Addr) string { return ip.Unmap().WithZone("").String() }
@@ -0,0 +1,80 @@
1
+ package deadline
2
+
3
+ import (
4
+ "context"
5
+ "database/sql"
6
+ "fmt"
7
+
8
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/shared/security"
9
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/shared/web"
10
+ )
11
+
12
+ // SubmissionGate is the one place that decides whether a state-limited write
13
+ // may proceed (PRD 5, D34, OI-18).
14
+ type SubmissionGate interface {
15
+ // Precheck is read-only. It runs before the request body is read, to keep
16
+ // the error precedence of PRD 6.1, and never replaces AssertOpenInTx.
17
+ Precheck(ctx context.Context, actor security.Principal) error
18
+ // AssertOpenInTx is the authoritative check. The tx parameter makes it
19
+ // impossible to call outside a write transaction (ADR G9).
20
+ AssertOpenInTx(ctx context.Context, tx *sql.Tx, actor security.Principal) error
21
+ }
22
+
23
+ // Gate implements SubmissionGate. Admins are exempt (PRD 5, OI-18).
24
+ type Gate struct{ Store Store }
25
+
26
+ var _ SubmissionGate = Gate{}
27
+
28
+ // Precheck reads the deadline outside any transaction.
29
+ func (g Gate) Precheck(ctx context.Context, actor security.Principal) error {
30
+ if actor.IsAdmin() {
31
+ return nil
32
+ }
33
+ snap, err := g.Store.Snapshot(ctx)
34
+ if err != nil {
35
+ return err
36
+ }
37
+ return check(snap.State())
38
+ }
39
+
40
+ // AssertOpenInTx reads the deadline and NOW(6) in one statement on tx, without
41
+ // a lock (PRD 5): a write racing a deadline change may land on either side.
42
+ func (g Gate) AssertOpenInTx(ctx context.Context, tx *sql.Tx, actor security.Principal) error {
43
+ if actor.IsAdmin() {
44
+ return nil
45
+ }
46
+ snap, err := TxStore{Tx: tx}.Snapshot(ctx)
47
+ if err != nil {
48
+ return err
49
+ }
50
+ return check(snap.State())
51
+ }
52
+
53
+ // TxStore reads the deadline through an open transaction.
54
+ type TxStore struct{ Tx *sql.Tx }
55
+
56
+ // Snapshot returns the deadline and NOW(6) from one statement.
57
+ func (s TxStore) Snapshot(ctx context.Context) (Snapshot, error) {
58
+ var snap Snapshot
59
+ var d sql.NullTime
60
+ err := s.Tx.QueryRowContext(ctx, `SELECT deadline, NOW(6) FROM contest_setting WHERE id = 1`).Scan(&d, &snap.Now)
61
+ if err != nil {
62
+ return Snapshot{}, fmt.Errorf("read contest_setting: %w", err)
63
+ }
64
+ if d.Valid {
65
+ snap.Deadline = &d.Time
66
+ }
67
+ return snap, nil
68
+ }
69
+
70
+ // check maps a state to the 403 a User write gets in it.
71
+ func check(s State) error {
72
+ switch s {
73
+ case "OPEN":
74
+ return nil
75
+ case "NOT_OPEN":
76
+ return web.NewError(web.CodeSubmissionNotOpen, "submissions are not open yet")
77
+ default:
78
+ return web.NewError(web.CodeSubmissionClosed, "the submission deadline has passed")
79
+ }
80
+ }
@@ -0,0 +1,67 @@
1
+ //go:build integration
2
+
3
+ package deadline_test
4
+
5
+ import (
6
+ "context"
7
+ "database/sql"
8
+ "errors"
9
+ "testing"
10
+
11
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/deadline"
12
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/shared/db"
13
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/shared/dbtest"
14
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/shared/security"
15
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/shared/web"
16
+ )
17
+
18
+ // TestGateAssertOpenInTx runs the authoritative check inside a real write
19
+ // transaction for NOT_OPEN, OPEN and CLOSED, for a User and an Admin.
20
+ func TestGateAssertOpenInTx(t *testing.T) {
21
+ ctx := context.Background()
22
+ sdb := dbtest.New(t)
23
+ g := deadline.Gate{Store: deadline.SQLStore{DB: sdb}}
24
+ user := security.Principal{Role: security.RoleUser}
25
+ admin := security.Principal{Role: security.RoleAdmin}
26
+
27
+ assert := func(actor security.Principal) error {
28
+ _, err := db.InTx(ctx, sdb, func(ctx context.Context, tx *sql.Tx) error {
29
+ return g.AssertOpenInTx(ctx, tx, actor)
30
+ })
31
+ return err
32
+ }
33
+ set := func(t *testing.T, expr string) {
34
+ t.Helper()
35
+ if _, err := sdb.ExecContext(ctx, "UPDATE contest_setting SET deadline = "+expr+" WHERE id = 1"); err != nil {
36
+ t.Fatal(err)
37
+ }
38
+ }
39
+ code := func(err error) web.ErrorCode {
40
+ var e *web.Error
41
+ if errors.As(err, &e) {
42
+ return e.Code
43
+ }
44
+ return ""
45
+ }
46
+
47
+ if got := code(assert(user)); got != web.CodeSubmissionNotOpen {
48
+ t.Fatalf("NOT_OPEN user: %q", got)
49
+ }
50
+ if err := assert(admin); err != nil {
51
+ t.Fatalf("NOT_OPEN admin: %v", err)
52
+ }
53
+ set(t, "NOW() + INTERVAL 1 HOUR")
54
+ if err := assert(user); err != nil {
55
+ t.Fatalf("OPEN user: %v", err)
56
+ }
57
+ set(t, "NOW() - INTERVAL 1 HOUR")
58
+ if got := code(assert(user)); got != web.CodeSubmissionClosed {
59
+ t.Fatalf("CLOSED user: %q", got)
60
+ }
61
+ if err := assert(admin); err != nil {
62
+ t.Fatalf("CLOSED admin: %v", err)
63
+ }
64
+ if got := code(g.Precheck(ctx, user)); got != web.CodeSubmissionClosed {
65
+ t.Fatalf("CLOSED precheck: %q", got)
66
+ }
67
+ }
@@ -0,0 +1,61 @@
1
+ package deadline_test
2
+
3
+ import (
4
+ "context"
5
+ "errors"
6
+ "testing"
7
+ "time"
8
+
9
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/deadline"
10
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/shared/security"
11
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/shared/web"
12
+ )
13
+
14
+ func TestGatePrecheck(t *testing.T) {
15
+ now := time.Date(2026, 11, 8, 12, 0, 0, 0, time.UTC)
16
+ future, past := now.Add(time.Hour), now.Add(-time.Hour)
17
+ user := security.Principal{Role: security.RoleUser}
18
+ admin := security.Principal{Role: security.RoleAdmin}
19
+
20
+ tests := []struct {
21
+ name string
22
+ d *time.Time
23
+ actor security.Principal
24
+ want web.ErrorCode
25
+ }{
26
+ {"user NOT_OPEN", nil, user, web.CodeSubmissionNotOpen},
27
+ {"user OPEN", &future, user, ""},
28
+ {"user CLOSED", &past, user, web.CodeSubmissionClosed},
29
+ {"user at the deadline instant is CLOSED", &now, user, web.CodeSubmissionClosed},
30
+ {"admin NOT_OPEN", nil, admin, ""},
31
+ {"admin CLOSED", &past, admin, ""},
32
+ }
33
+ for _, tc := range tests {
34
+ t.Run(tc.name, func(t *testing.T) {
35
+ g := deadline.Gate{Store: fakeStore{snap: deadline.Snapshot{Deadline: tc.d, Now: now}}}
36
+ err := g.Precheck(context.Background(), tc.actor)
37
+ if tc.want == "" {
38
+ if err != nil {
39
+ t.Fatalf("got %v", err)
40
+ }
41
+ return
42
+ }
43
+ var e *web.Error
44
+ if !errors.As(err, &e) || e.Code != tc.want || e.Status != 403 {
45
+ t.Fatalf("got %v, want %s/403", err, tc.want)
46
+ }
47
+ })
48
+ }
49
+ }
50
+
51
+ func TestGatePrecheckStoreError(t *testing.T) {
52
+ boom := errors.New("db down")
53
+ g := deadline.Gate{Store: fakeStore{err: boom}}
54
+ if err := g.Precheck(context.Background(), security.Principal{Role: security.RoleUser}); !errors.Is(err, boom) {
55
+ t.Fatalf("got %v", err)
56
+ }
57
+ // Admin is exempt, so the store is not even read.
58
+ if err := g.Precheck(context.Background(), security.Principal{Role: security.RoleAdmin}); err != nil {
59
+ t.Fatalf("admin: %v", err)
60
+ }
61
+ }