@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
@@ -25,11 +25,23 @@ type Row struct {
25
25
  Method string `json:"method"`
26
26
  Path string `json:"path"`
27
27
  Body json.RawMessage `json:"body"`
28
+ Multipart *Multipart `json:"multipart"`
28
29
  CSRF string `json:"csrf"`
30
+ State string `json:"state"`
29
31
  OutsideSpec bool `json:"outsideSpec"`
30
32
  Expect map[string]Cell `json:"expect"`
31
33
  }
32
34
 
35
+ // Multipart is a multipart/form-data body: text parts and one file part.
36
+ type Multipart struct {
37
+ Fields map[string]string `json:"fields"`
38
+ File struct {
39
+ Field string `json:"field"`
40
+ Filename string `json:"filename"`
41
+ Content string `json:"content"` // "pdf": a minimal valid PDF
42
+ } `json:"file"`
43
+ }
44
+
33
45
  // Actors in PRD §7 order. The harness has a fixture for each one it lists in
34
46
  // fixtures; a row naming any other actor fails loudly instead of passing.
35
47
  var Actors = []string{"visitor", "user", "member", "leader", "admin"}
@@ -3,10 +3,15 @@
3
3
  package matrix
4
4
 
5
5
  import (
6
+ "bytes"
7
+ "context"
6
8
  "database/sql"
7
9
  "encoding/json"
10
+ "errors"
8
11
  "fmt"
12
+ "io"
9
13
  "log/slog"
14
+ "mime/multipart"
10
15
  "net/http"
11
16
  "net/http/httptest"
12
17
  "strings"
@@ -20,8 +25,11 @@ import (
20
25
  "github.com/brian-huanggg/AwardRegistration/backend/internal/audit"
21
26
  "github.com/brian-huanggg/AwardRegistration/backend/internal/auth"
22
27
  "github.com/brian-huanggg/AwardRegistration/backend/internal/deadline"
28
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/idea"
23
29
  "github.com/brian-huanggg/AwardRegistration/backend/internal/server"
30
+ shareddb "github.com/brian-huanggg/AwardRegistration/backend/internal/shared/db"
24
31
  "github.com/brian-huanggg/AwardRegistration/backend/internal/shared/dbtest"
32
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/storage"
25
33
  "github.com/brian-huanggg/AwardRegistration/backend/test/apispec"
26
34
  )
27
35
 
@@ -57,10 +65,21 @@ func newEnv(t *testing.T) *env {
57
65
  Logouts: auth.LogoutHandler{Logouts: &auth.Logouts{Sessions: sessions, Audit: auditSvc}, Fail: errs.Write},
58
66
  Me: auth.MeHandler{Sessions: sessions, Fail: errs.Write},
59
67
  Account: account.Handler{
60
- Service: &account.Service{DB: db, Audit: auditSvc, Policy: account.StubPolicy{}},
68
+ Service: &account.Service{DB: db, Audit: auditSvc, Policy: account.DefaultPolicy()},
61
69
  Fail: errs.Write,
62
70
  },
63
71
  Deadline: deadline.Handler{Store: deadline.SQLStore{DB: db}},
72
+ Ideas: idea.Handler{
73
+ Service: &idea.Service{
74
+ DB: db, Audit: auditSvc,
75
+ Gate: deadline.Gate{Store: deadline.SQLStore{DB: db}},
76
+ Slides: server.IdeaSlideRules(),
77
+ Objects: discardObjects{},
78
+ Aborter: discardObjects{},
79
+ },
80
+ ExpectedFilename: storage.ExpectedFilenameBase,
81
+ Fail: errs.Write,
82
+ },
64
83
  }
65
84
  chain := server.NewPublic(log, server.Mount(si),
66
85
  auth.Middleware(sessions, server.IsPublic, log),
@@ -68,6 +87,40 @@ func newEnv(t *testing.T) *env {
68
87
  return &env{db: db, plain: chain, checked: apispec.Load(t).Validate(t, chain)}
69
88
  }
70
89
 
90
+ // discardObjects stands in for MinIO: the matrix is about who may call what,
91
+ // not about storage.
92
+ type discardObjects struct{}
93
+
94
+ func (discardObjects) Put(_ context.Context, _ string, r io.Reader, _ int64, _ string) error {
95
+ _, err := io.Copy(io.Discard, r)
96
+ return err
97
+ }
98
+
99
+ // Get is never reached by the matrix rows (they download only missing Ideas).
100
+ func (discardObjects) Get(context.Context, string) (io.ReadCloser, int64, error) {
101
+ return nil, 0, errors.New("matrix: no objects")
102
+ }
103
+
104
+ func (discardObjects) AbortUpload(context.Context, string, shareddb.Outcome) {}
105
+
106
+ // stateDeadline is the deadline expression that puts the contest in a state.
107
+ var stateDeadline = map[string]string{
108
+ "NOT_OPEN": "NULL",
109
+ "OPEN": "NOW(6) + INTERVAL 1 DAY",
110
+ "CLOSED": "NOW(6) - INTERVAL 1 DAY",
111
+ }
112
+
113
+ func (e *env) setState(t *testing.T, state string) {
114
+ t.Helper()
115
+ expr, ok := stateDeadline[state]
116
+ if !ok {
117
+ t.Fatalf("unknown state %q", state)
118
+ }
119
+ if _, err := e.db.Exec(`UPDATE contest_setting SET deadline = ` + expr + ` WHERE id = 1`); err != nil {
120
+ t.Fatal(err)
121
+ }
122
+ }
123
+
71
124
  func (e *env) do(h http.Handler, req *http.Request) *httptest.ResponseRecorder {
72
125
  rec := httptest.NewRecorder()
73
126
  h.ServeHTTP(rec, req)
@@ -90,10 +143,11 @@ func (e *env) addUser(t *testing.T, empNo, role string) {
90
143
  }
91
144
  }
92
145
 
93
- // actor is one caller of the matrix: its employee number and, when logged in,
94
- // its session cookie.
146
+ // actor is one caller of the matrix: its employee number, account name and,
147
+ // when logged in, its session cookie.
95
148
  type actor struct {
96
149
  empNo string
150
+ name string
97
151
  session *http.Cookie
98
152
  }
99
153
 
@@ -124,7 +178,7 @@ func (e *env) newActor(t *testing.T, name string) actor {
124
178
  if rec.Code != 200 {
125
179
  t.Fatalf("login %s: %d %s", name, rec.Code, rec.Body)
126
180
  }
127
- return actor{empNo: empNo, session: cookie(rec, auth.SessionCookie)}
181
+ return actor{empNo: empNo, name: "N " + empNo, session: cookie(rec, auth.SessionCookie)}
128
182
  }
129
183
 
130
184
  func cookie(rec *httptest.ResponseRecorder, name string) *http.Cookie {
@@ -161,7 +215,11 @@ func (e *env) request(t *testing.T, r Row, a actor) *http.Request {
161
215
  body = strings.NewReader(s)
162
216
  }
163
217
  var req *http.Request
164
- if body != nil {
218
+ if r.Multipart != nil {
219
+ buf, ct := multipartBody(t, r.Multipart, a)
220
+ req = httptest.NewRequest(r.Method, r.Path, buf)
221
+ req.Header.Set("Content-Type", ct)
222
+ } else if body != nil {
165
223
  req = httptest.NewRequest(r.Method, r.Path, body)
166
224
  req.Header.Set("Content-Type", "application/json")
167
225
  } else {
@@ -180,9 +238,38 @@ func (e *env) request(t *testing.T, r Row, a actor) *http.Request {
180
238
  return req
181
239
  }
182
240
 
241
+ func multipartBody(t *testing.T, m *Multipart, a actor) (*bytes.Buffer, string) {
242
+ t.Helper()
243
+ sub := strings.NewReplacer("${actorEmpNo}", a.empNo, "${actorName}", a.name)
244
+ var buf bytes.Buffer
245
+ mw := multipart.NewWriter(&buf)
246
+ for k, v := range m.Fields {
247
+ if err := mw.WriteField(k, sub.Replace(v)); err != nil {
248
+ t.Fatal(err)
249
+ }
250
+ }
251
+ w, err := mw.CreateFormFile(m.File.Field, sub.Replace(m.File.Filename))
252
+ if err != nil {
253
+ t.Fatal(err)
254
+ }
255
+ switch m.File.Content {
256
+ case "pdf":
257
+ _, _ = w.Write([]byte("%PDF-1.7\nmatrix\n%%EOF\n"))
258
+ default:
259
+ t.Fatalf("unknown file content %q", m.File.Content)
260
+ }
261
+ if err := mw.Close(); err != nil {
262
+ t.Fatal(err)
263
+ }
264
+ return &buf, mw.FormDataContentType()
265
+ }
266
+
183
267
  func TestPermissionMatrix(t *testing.T) {
184
268
  e := newEnv(t)
185
269
  for _, r := range load(t) {
270
+ if r.State != "" {
271
+ e.setState(t, r.State)
272
+ }
186
273
  for _, name := range Actors {
187
274
  want, ok := r.Expect[name]
188
275
  if !ok {
@@ -0,0 +1,46 @@
1
+ //go:build integration
2
+
3
+ package migration_test
4
+
5
+ import (
6
+ "strings"
7
+ "testing"
8
+
9
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/shared/dbtest"
10
+ )
11
+
12
+ func TestPendingFileDeletionRules(t *testing.T) {
13
+ d := dbtest.New(t)
14
+ ins := func(key, reason string) error {
15
+ _, err := d.Exec(`INSERT INTO pending_file_deletion (object_key, reason) VALUES (?, ?)`, key, reason)
16
+ return err
17
+ }
18
+ for _, r := range []string{"REPLACED", "DELETED", "UPLOAD_ABORTED"} {
19
+ if err := ins("slides/a/b.pdf", r); err != nil {
20
+ t.Errorf("reason %s (same key again): want accepted, got %v", r, err)
21
+ }
22
+ }
23
+ if err := ins(strings.Repeat("k", 128), "DELETED"); err != nil {
24
+ t.Errorf("128-char key: %v", err)
25
+ }
26
+ for name, c := range map[string][2]string{
27
+ "unknown reason": {"slides/x.pdf", "EXPIRED"},
28
+ "lower-case reason": {"slides/x.pdf", "replaced"},
29
+ "empty key": {"", "DELETED"},
30
+ "129-char key": {strings.Repeat("k", 129), "DELETED"},
31
+ } {
32
+ if err := ins(c[0], c[1]); err == nil {
33
+ t.Errorf("%s: want rejected", name)
34
+ }
35
+ }
36
+
37
+ var attempts int
38
+ var due bool
39
+ if err := d.QueryRow(`SELECT attempts, next_attempt_at <= NOW(6) AND created_at <= NOW(6)
40
+ FROM pending_file_deletion WHERE reason = 'DELETED' LIMIT 1`).Scan(&attempts, &due); err != nil {
41
+ t.Fatal(err)
42
+ }
43
+ if attempts != 0 || !due {
44
+ t.Errorf("defaults: attempts=%d due=%v, want 0 and due now", attempts, due)
45
+ }
46
+ }
@@ -40,6 +40,6 @@ Every write (`POST`, `PUT`, `DELETE`, including login, register and logout) MUST
40
40
  Notes:
41
41
 
42
42
  - `DELETE /ideas/{id}/members/{empNo}` works on both `PENDING` and `ACCEPTED` rows (reject or remove); `DELETE …/members/me` works on the caller's own row (withdraw or leave). `me` is never a 6-digit employee ID, so the two paths cannot collide (PRD §6.7).
43
- - List endpoints enforce a maximum page size of 100 (`GET /ideas`: `page`, `size`, filters, `sort`, `order`; PRD §6.6). `GET /admin/audit-logs` uses keyset pagination with `cursor` and `limit` (PRD §6.11).
43
+ - List endpoints enforce a maximum page size of 100 (`GET /ideas`: `page`, `size`, filters, `q`, `sort`, `order`; PRD §6.6). `GET /admin/audit-logs` uses keyset pagination with `cursor` and `limit` (PRD §6.11).
44
44
  - Unknown paths: 401 without a session, 404 with one; wrong method 405; unsupported `Accept` 406 (PRD §6.1, OI-15).
45
45
  - Operations endpoints are on the management port and are not routed by the public ingress (PRD §8.3).
@@ -37,7 +37,7 @@ Format: RFC 9457 `application/problem+json`.
37
37
 
38
38
  - `code` is the stable contract; `detail` is English and not for display (the UI localizes by `code`).
39
39
  - With several field errors, `errors[]` lists all of them in form-field order; the top-level `code` equals `errors[0].code` when that is a registered specific code, otherwise `VALIDATION_FAILED`.
40
- - Generic field codes: `REQUIRED`, `INVALID_FORMAT`, `OUT_OF_RANGE`, `INVALID_ENUM`. Per-field rules (which error a field reports first) are in PRD §6.2. The password is the exception: `errors[]` lists every violated rule (PRD §6.4).
40
+ - Generic field codes: `REQUIRED`, `INVALID_FORMAT`, `OUT_OF_RANGE`, `INVALID_ENUM`. Per-field rules (which error a field reports first) are in PRD §6.2. The password is the exception: `errors[]` lists every violated rule (PRD §6.4), each as `{ "field": "password", "code": "PASSWORD_POLICY", "rule": "<rule id>" }` in table order. `rule` appears only on these entries.
41
41
  - `requestId` is a server-generated UUID. An incoming `X-Request-Id` is reused only if it is a valid UUID. It is returned in the response header and the error body.
42
42
  - 500 responses carry only `INTERNAL_ERROR` and `requestId`: no stack traces, SQL or paths.
43
43
  - A malformed ID in a path is a 404, not a 400.
@@ -149,7 +149,7 @@ Next deliverables: ADR drafts for the decisions below; Flyway migration plan for
149
149
  | D26 | No `job_lease` table in v1 | Idempotent operations and `SKIP LOCKED` are enough | Decided |
150
150
  | D27 | MariaDB 10.8 LTS with native `UUID` and `INET6` | Version confirmed by the owner | **Reopened** (D45) |
151
151
  | D28 | No dedicated Spring Boot 4 compatibility spike | Owner decision; risk accepted | **Superseded: no Spring in the Go backend (OI-31 closed)** (was: Decided (OI-31)) |
152
- | D29 | Split `slides` into `storage` (no dependency on `idea`) and `slides` (HTTP orchestration on top of `idea` + `storage`) | Removes the `idea` ↔ `slides` cycle | Decided |
152
+ | D29 | Split `slides` into `storage` (no dependency on `idea`) and `slides` (HTTP orchestration on top of `idea` + `storage`) | Removes the `idea` ↔ `slides` cycle. `idea → storage` is allowed (Idea delete, `Sequence.md` §5); the forbidden direction is `storage → idea` | Decided |
153
153
  | D30 | Split `auth` into `account` and `auth`; `account` defines `SessionRevoker`, `auth` implements it | Smaller modules; Vault uncertainty isolated; no cycle | Decided |
154
154
  | D31 | Repositories use `JdbcClient`, hand-written SQL owned by each module, no JPA | Locking, upserts, atomic session statement and visibility query are SQL by design | **Superseded by ADR G1 (sqlc, hand-written SQL per module; intent unchanged)** (was: Decided) |
155
155
  | D32 | `shared` depends on no business module and contains no business concept | Reusable, trivially testable kernel | Decided |
@@ -52,6 +52,7 @@ Same graph and rules as `Backend.md` §2.1. Go mechanics:
52
52
  - **`SubmissionGate`** (D34, OI-18):
53
53
  - `Precheck(ctx, actor) error` — read-only;
54
54
  - `AssertOpenInTx(ctx, tx *sql.Tx, actor) error` — the `tx` parameter makes it impossible to call outside a write transaction [ADR G9].
55
+ - **`idea → storage`** (allowed; bd 1ve): `DELETE /ideas/{id}` (`Sequence.md` §5) inserts `pending_file_deletion` in its own transaction and deletes the object after commit, so `idea` needs `storage`. `idea` declares a small interface (for example `ObjectDeleter { Delete(ctx, key string) error }`) that `storage` implements, and `main.go` injects it. `storage` still imports no `idea` (D29), so the graph stays acyclic.
55
56
  - Allowed edges are listed once in a table inside the import-graph test (§2.4) and compared with the real graph.
56
57
  ### 2.2 Package layout inside a module **[ADR G6]**
57
58
 
@@ -323,7 +324,7 @@ Same as `Backend.md` §8. The visibility query is one sqlc query in `idea/intern
323
324
 
324
325
  Same rules as `Backend.md` §9, including the Vault sync table. Go mechanics **[Proposed]**:
325
326
 
326
- - Background work (Vault sync, session and throttle purge) runs as goroutines started in `main.go` with a `time.Ticker` plus random jitter; each stops when the root context is cancelled at shutdown.
327
+ - Background work (Vault sync, session and throttle purge) runs as goroutines started in `main.go` through `shared/job.Run`: a timer re-armed after each run with the interval plus a fresh random jitter (the first run also waits a random jitter), so replicas drift apart. Each stops when the root context is cancelled at shutdown, and `main` waits for it before closing the pool. No leader is elected, so every job must be idempotent and safe on several replicas at once. The auth purge (B1b-07) runs every 5 min + up to 1 min and deletes in batches of 1000 with `DELETE … LIMIT`; a deadlock or lock-wait timeout ends that table's turn without an error.
327
328
  - Readiness waits for the first successful Vault read (`atomic.Bool` read by the readiness handler).
328
329
  - The orphan-file claim (`SELECT … FOR UPDATE SKIP LOCKED`) is generated by sqlc as written (spike case c03).
329
330
  ---
@@ -6,6 +6,8 @@ WORKDIR /repo/frontend
6
6
  COPY frontend/package.json frontend/package-lock.json ./
7
7
  RUN npm ci
8
8
  COPY testdata /repo/testdata
9
+ # The common-password list lives with the backend rule; the register page lazy-loads the same file.
10
+ COPY backend/internal/account/internal/passwords/common-passwords.txt /repo/backend/internal/account/internal/passwords/
9
11
  COPY frontend/ ./
10
12
  RUN npm run build
11
13
 
@@ -46,6 +46,7 @@
46
46
  "tailwindcss": "^4.3.5",
47
47
  "typescript": "~6.0",
48
48
  "typescript-eslint": "^8.72.1",
49
+ "undici": "^8.12.4",
49
50
  "vite": "^8.3.6",
50
51
  "vitest": "^5.1.0",
51
52
  "vitest-axe": "^0.1.0"
@@ -54,6 +54,7 @@
54
54
  "tailwindcss": "^4.3.5",
55
55
  "typescript": "~6.0",
56
56
  "typescript-eslint": "^8.72.1",
57
+ "undici": "^8.12.4",
57
58
  "vite": "^8.3.6",
58
59
  "vitest": "^5.1.0",
59
60
  "vitest-axe": "^0.1.0"
@@ -1,18 +1,27 @@
1
- import { useEffect } from 'react';
1
+ import { useCallback, useEffect } from 'react';
2
2
  import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
3
3
  import { RouterProvider, createBrowserRouter, type DataRouter } from 'react-router';
4
4
  import { setUnauthenticatedHandler } from '@/shared/api';
5
5
  import { routes } from './router';
6
- import { handleSessionEnded } from './session';
6
+ import { IdleWarning } from '@/features/auth/IdleWarning';
7
+ import { handleSessionEnded, useMe } from './session';
7
8
 
8
9
  const queryClient = new QueryClient();
9
10
  const browserRouter = createBrowserRouter(routes);
10
11
 
12
+ /** Mounted inside the providers so it can read `me`; ends the session like a 401 when the idle time is up. */
13
+ function Idle({ router, client }: { router: DataRouter; client: QueryClient }) {
14
+ const { data: me } = useMe();
15
+ const onExpire = useCallback(() => handleSessionEnded(client, router), [client, router]);
16
+ return <IdleWarning signedIn={!!me} onExpire={onExpire} />;
17
+ }
18
+
11
19
  export function App({ router = browserRouter, client = queryClient }: { router?: DataRouter; client?: QueryClient }) {
12
20
  useEffect(() => setUnauthenticatedHandler(() => handleSessionEnded(client, router)), [client, router]);
13
21
  return (
14
22
  <QueryClientProvider client={client}>
15
23
  <RouterProvider router={router} />
24
+ <Idle router={router} client={client} />
16
25
  </QueryClientProvider>
17
26
  );
18
27
  }
@@ -0,0 +1,308 @@
1
+ import { QueryClient } from '@tanstack/react-query';
2
+ import { render, screen, waitFor, within } from '@testing-library/react';
3
+ import userEvent from '@testing-library/user-event';
4
+ import { http, HttpResponse } from 'msw';
5
+ import { createMemoryRouter } from 'react-router';
6
+ import { axe } from 'vitest-axe';
7
+ import { clearCsrfToken } from '@/shared/api';
8
+ import { problem } from '@/shared/api/mocks/problem';
9
+ import { server } from '@/shared/api/mocks/server';
10
+ import { initI18n, type Locale } from '@/shared/i18n';
11
+ import { App } from '../App';
12
+ import { routes } from '../router';
13
+
14
+ const API = 'http://localhost:3000/api/v1';
15
+ const ME = { empNo: '089290', name: '王小明', email: 'wang@example.com', role: 'USER' };
16
+ const BASE = 'I_BSID_王小明_089290_成立供應商永續學院';
17
+ const PDF = '%PDF-1.7\n';
18
+ const ID = '0193a1b2-0000-7000-8000-000000000001';
19
+
20
+ type State = 'OPEN' | 'CLOSED' | 'NOT_OPEN';
21
+
22
+ interface Sent {
23
+ fields: Record<string, string>;
24
+ file: { name: string; size: number } | null;
25
+ csrf: string | null;
26
+ }
27
+
28
+ async function open({ locale = 'en', state = 'OPEN' as State } = {}) {
29
+ clearCsrfToken();
30
+ server.use(
31
+ http.get(`${API}/auth/me`, () => HttpResponse.json(ME)),
32
+ http.get(`${API}/deadline`, () => HttpResponse.json({ deadline: null, state, serverTime: '2026-11-08T00:00:00.000Z' })),
33
+ http.get(`${API}/auth/csrf`, () => HttpResponse.json({ token: 'tok' })),
34
+ );
35
+ await initI18n(locale as Locale);
36
+ const router = createMemoryRouter(routes, { initialEntries: ['/ideas/new'] });
37
+ const client = new QueryClient();
38
+ const view = render(<App router={router} client={client} />);
39
+ await screen.findByRole('heading', { level: 1, name: locale === 'en' ? 'Create an Idea' : '建立提案' });
40
+ return { router, client, user: userEvent.setup(), ...view };
41
+ }
42
+
43
+ function createReturns(respond: () => Response | Promise<Response>) {
44
+ const sent: Sent[] = [];
45
+ server.use(
46
+ http.post(`${API}/ideas`, async ({ request }) => {
47
+ const form = await request.formData();
48
+ const fields: Record<string, string> = {};
49
+ let file: Sent['file'] = null;
50
+ for (const [k, v] of form.entries()) {
51
+ if (typeof v === 'string') fields[k] = v;
52
+ else file = { name: v.name, size: v.size };
53
+ }
54
+ sent.push({ fields, file, csrf: request.headers.get('X-CSRF-Token') });
55
+ return respond();
56
+ }),
57
+ );
58
+ return sent;
59
+ }
60
+
61
+ type User = ReturnType<typeof userEvent.setup>;
62
+
63
+ async function fillText(user: User, v: { name?: string; summary?: string } = {}) {
64
+ await user.selectOptions(screen.getByLabelText('Category'), 'COOL_IDEA');
65
+ await user.selectOptions(screen.getByLabelText('Organization'), 'BSID');
66
+ await user.selectOptions(screen.getByLabelText('Direction'), 'RESPONSIBLE_SUPPLY_CHAIN');
67
+ await user.type(screen.getByLabelText('Idea name'), v.name ?? ' 成立供應商永續學院 ');
68
+ await user.type(screen.getByLabelText('Summary'), v.summary ?? 'Train suppliers.');
69
+ }
70
+
71
+ const pick = (user: User, name: string, content: string = PDF) => user.upload(screen.getByLabelText('Slide file'), new File([content], name));
72
+
73
+ const submit = (user: User) => user.click(screen.getByRole('button', { name: 'Submit Idea' }));
74
+
75
+ describe('Create Idea page: markup and accessibility', () => {
76
+ it('labels and requires every field; the Leader comes from the session', async () => {
77
+ await open();
78
+ for (const label of ['Category', 'Organization', 'Direction', 'Idea name', 'Summary', 'Slide file']) {
79
+ expect(screen.getByLabelText(label)).toBeRequired();
80
+ }
81
+ expect(screen.getByText('王小明 (089290)')).toBeInTheDocument();
82
+ expect(screen.getByLabelText('Slide file')).toHaveAttribute('type', 'file');
83
+ expect(screen.getByLabelText('Idea name')).not.toHaveAttribute('maxlength'); // counted in code points, not UTF-16
84
+ });
85
+
86
+ it('shows the zh-TW working-draft labels (OI-17)', async () => {
87
+ await open({ locale: 'zh-TW' });
88
+ const options = within(screen.getByLabelText('類別')).getAllByRole('option').map((o) => o.textContent);
89
+ expect(options).toEqual(['請選擇…', '酷點子獎', '卓越影響獎']);
90
+ expect(within(screen.getByLabelText('方向')).getByRole('option', { name: '關懷弱勢' })).toBeInTheDocument();
91
+ });
92
+
93
+ it.each(['en', 'zh-TW'] as const)('has no axe violations (%s), with the expected name and with errors shown', async (locale) => {
94
+ const { container, user } = await open({ locale });
95
+ expect((await axe(container)).violations).toEqual([]);
96
+ if (locale === 'en') {
97
+ await fillText(user);
98
+ expect(await screen.findByText(`${BASE}.pdf`)).toBeInTheDocument();
99
+ expect((await axe(container)).violations).toEqual([]);
100
+ }
101
+ await user.click(screen.getByRole('button', { name: locale === 'en' ? 'Submit Idea' : '送出提案' }));
102
+ expect(await screen.findByRole('alert')).toBeInTheDocument();
103
+ expect((await axe(container)).violations).toEqual([]);
104
+ });
105
+ });
106
+
107
+ describe('Create Idea page: code-point counters', () => {
108
+ it('count the cleaned value in code points and are linked to their field', async () => {
109
+ const { user } = await open();
110
+ const name = screen.getByLabelText('Idea name');
111
+ expect(name.getAttribute('aria-describedby')).toContain('name-count');
112
+ expect(document.getElementById('name-count')).toHaveTextContent('0 / 75 characters');
113
+ await user.type(name, ' 😀😀 a\tb ');
114
+ expect(document.getElementById('name-count')).toHaveTextContent('6 / 75 characters'); // "😀😀 a b"
115
+ await user.type(screen.getByLabelText('Summary'), '創意\n\n😀');
116
+ expect(document.getElementById('summary-count')).toHaveTextContent('5 / 300 characters');
117
+ });
118
+
119
+ it('76 code points are rejected before any request (OUT_OF_RANGE)', async () => {
120
+ const { user } = await open();
121
+ const sent = createReturns(() => HttpResponse.json({}, { status: 201 }));
122
+ await fillText(user, { name: '😀'.repeat(76) });
123
+ expect(document.getElementById('name-count')).toHaveTextContent('76 / 75 characters');
124
+ await submit(user);
125
+ expect(await screen.findByRole('link', { name: 'Idea name: The value is out of range.' })).toBeInTheDocument();
126
+ expect(sent).toHaveLength(0);
127
+ });
128
+ });
129
+
130
+ describe('Create Idea page: expected filename (PRD §6.8, OI-21)', () => {
131
+ it('appears once category, org and name are given, for both extensions, from the cleaned name', async () => {
132
+ const { user } = await open();
133
+ expect(screen.getByText(/to see the expected file name/)).toBeInTheDocument();
134
+ await fillText(user);
135
+ expect(screen.getByText(`${BASE}.pdf`)).toBeInTheDocument();
136
+ expect(screen.getByText(`${BASE}.pptx`)).toBeInTheDocument();
137
+ await user.selectOptions(screen.getByLabelText('Category'), 'OUTSTANDING_IMPACT');
138
+ expect(screen.getByText(`O${BASE.slice(1)}.pdf`)).toBeInTheDocument();
139
+ });
140
+
141
+ it('the copy buttons are named and copy the full name; the result is announced', async () => {
142
+ const { user } = await open();
143
+ await fillText(user);
144
+ await user.click(screen.getByRole('button', { name: 'Copy the PPTX file name' }));
145
+ expect(await navigator.clipboard.readText()).toBe(`${BASE}.pptx`);
146
+ expect(screen.getByText('File name copied.')).toHaveAttribute('role', 'status');
147
+ });
148
+
149
+ it('a name whose expected filename exceeds 255 UTF-8 bytes is FILENAME_TOO_LONG on the name', async () => {
150
+ const { user } = await open();
151
+ const sent = createReturns(() => HttpResponse.json({}, { status: 201 }));
152
+ await fillText(user, { name: '創'.repeat(75) }); // 75 code points, 225 bytes in the name alone
153
+ await pick(user, 'x.pdf');
154
+ await submit(user);
155
+ expect(await screen.findByRole('link', { name: 'Idea name: The file name is too long.' })).toBeInTheDocument();
156
+ expect(sent).toHaveLength(0);
157
+ });
158
+ });
159
+
160
+ describe('Create Idea page: pre-check', () => {
161
+ it('an empty submit lists every field, focuses the summary and sends nothing', async () => {
162
+ const { user } = await open();
163
+ const sent = createReturns(() => HttpResponse.json({}, { status: 201 }));
164
+ await submit(user);
165
+ const summary = await screen.findByRole('alert');
166
+ await waitFor(() => expect(summary).toHaveFocus());
167
+ expect(within(summary).getAllByRole('link').map((l) => l.textContent)).toEqual([
168
+ 'Category: This field is required.',
169
+ 'Organization: This field is required.',
170
+ 'Direction: This field is required.',
171
+ 'Idea name: This field is required.',
172
+ 'Summary: This field is required.',
173
+ 'Slide file: This field is required.',
174
+ ]);
175
+ const slides = screen.getByLabelText('Slide file');
176
+ expect(slides).toHaveAttribute('aria-invalid', 'true');
177
+ expect(slides.getAttribute('aria-describedby')).toContain('slides-error');
178
+ await user.click(within(summary).getByRole('link', { name: /^Summary/ }));
179
+ expect(screen.getByLabelText('Summary')).toHaveFocus();
180
+ expect(sent).toHaveLength(0);
181
+ });
182
+
183
+ it('a name with a forbidden character: text errors only, the file is not checked yet', async () => {
184
+ const { user } = await open();
185
+ await fillText(user, { name: 'a/b' });
186
+ await pick(user, 'wrong.pdf');
187
+ await submit(user);
188
+ const summary = await screen.findByRole('alert');
189
+ expect(within(summary).getAllByRole('link').map((l) => l.textContent)).toEqual(['Idea name: The name contains characters that are not allowed.']);
190
+ });
191
+
192
+ it.each([
193
+ ['a wrong name', 'slides.pdf', PDF, `Slide file: The file name does not match the expected name. Expected: ${BASE}.pdf`],
194
+ ['an empty file', `${BASE}.pdf`, '', 'Slide file: The file is empty.'],
195
+ ['PDF bytes named .pptx', `${BASE}.pptx`, PDF, 'Slide file: This file type is not supported.'],
196
+ ])('%s is rejected before upload', async (_n, name, content, text) => {
197
+ const { user } = await open();
198
+ const sent = createReturns(() => HttpResponse.json({}, { status: 201 }));
199
+ await fillText(user);
200
+ await pick(user, name, content);
201
+ await submit(user);
202
+ expect(await screen.findByRole('link', { name: text })).toBeInTheDocument();
203
+ expect(sent).toHaveLength(0);
204
+ });
205
+ });
206
+
207
+ describe('Create Idea page: submit', () => {
208
+ it('sends one multipart request with the cleaned fields, the file and the CSRF token, then opens the new Idea', async () => {
209
+ const { user, router, client } = await open();
210
+ const detail = { id: ID, name: '成立供應商永續學院', expectedFilenameBase: BASE };
211
+ const sent = createReturns(() => HttpResponse.json(detail, { status: 201, headers: { Location: `/api/v1/ideas/${ID}` } }));
212
+ await fillText(user, { summary: ' Line 1\r\nLine 2 ' });
213
+ await pick(user, `${BASE}.pdf`);
214
+ await submit(user);
215
+ await waitFor(() => expect(router.state.location.pathname).toBe(`/ideas/${ID}`));
216
+ expect(sent).toEqual([
217
+ {
218
+ fields: { category: 'COOL_IDEA', org: 'BSID', direction: 'RESPONSIBLE_SUPPLY_CHAIN', name: '成立供應商永續學院', summary: 'Line 1\nLine 2' },
219
+ file: { name: `${BASE}.pdf`, size: PDF.length },
220
+ csrf: 'tok',
221
+ },
222
+ ]);
223
+ expect(client.getQueryData(['idea', ID])).toEqual(detail);
224
+ });
225
+
226
+ it('the button is disabled while the request is pending (no double submit)', async () => {
227
+ const { user, router } = await open();
228
+ let release!: () => void;
229
+ const gate = new Promise<void>((r) => (release = r));
230
+ const sent = createReturns(async () => {
231
+ await gate;
232
+ return HttpResponse.json({ id: ID }, { status: 201 });
233
+ });
234
+ await fillText(user);
235
+ await pick(user, `${BASE}.pdf`);
236
+ await submit(user);
237
+ expect(await screen.findByRole('button', { name: 'Submitting…' })).toBeDisabled();
238
+ release();
239
+ await waitFor(() => expect(router.state.location.pathname).toBe(`/ideas/${ID}`));
240
+ expect(sent).toHaveLength(1);
241
+ });
242
+
243
+ it('outside the submission window the button is disabled with a reason (not a guard)', async () => {
244
+ await open({ state: 'CLOSED' });
245
+ expect(await screen.findByText('Submissions are not open, so the Idea cannot be submitted now.')).toBeInTheDocument();
246
+ expect(screen.getByRole('button', { name: 'Submit Idea' })).toBeDisabled();
247
+ expect(screen.getByLabelText('Idea name')).toBeEnabled();
248
+ });
249
+ });
250
+
251
+ describe('Create Idea page: server answers', () => {
252
+ async function submitValid(respond: () => Response) {
253
+ const ctx = await open();
254
+ const sent = createReturns(respond);
255
+ await fillText(ctx.user);
256
+ await pick(ctx.user, `${BASE}.pdf`);
257
+ await submit(ctx.user);
258
+ return { ...ctx, sent };
259
+ }
260
+
261
+ it('422 FILENAME_MISMATCH shows the server’s expected name on the file field', async () => {
262
+ const expected = 'I_BSID_王小明_089290_Other.pdf';
263
+ await submitValid(() => problem(422, 'FILENAME_MISMATCH', { errors: [{ field: 'slides', code: 'FILENAME_MISMATCH', expected }] }));
264
+ expect(await screen.findByRole('link', { name: `Slide file: The file name does not match the expected name. Expected: ${expected}` })).toBeInTheDocument();
265
+ });
266
+
267
+ it('409 IDEA_DUPLICATE belongs to the name; nothing typed is lost', async () => {
268
+ const { router } = await submitValid(() => problem(409, 'IDEA_DUPLICATE'));
269
+ expect(await screen.findByRole('link', { name: 'Idea name: You already have an Idea with this name.' })).toBeInTheDocument();
270
+ expect(screen.getByLabelText('Idea name')).toHaveAttribute('aria-invalid', 'true');
271
+ expect(screen.getByLabelText('Summary')).toHaveValue('Train suppliers.');
272
+ expect(router.state.location.pathname).toBe('/ideas/new');
273
+ });
274
+
275
+ it('422 text errors map to their fields', async () => {
276
+ await submitValid(() =>
277
+ problem(422, 'VALIDATION_FAILED', {
278
+ errors: [
279
+ { field: 'direction', code: 'INVALID_ENUM' },
280
+ { field: 'summary', code: 'OUT_OF_RANGE' },
281
+ ],
282
+ }),
283
+ );
284
+ expect(await screen.findByRole('link', { name: 'Direction: Please choose one of the listed options.' })).toBeInTheDocument();
285
+ expect(screen.getByRole('link', { name: 'Summary: The value is out of range.' })).toBeInTheDocument();
286
+ });
287
+
288
+ it.each([
289
+ ['413 from the ingress (not JSON)', () => new HttpResponse('<html>413</html>', { status: 413, headers: { 'Content-Type': 'text/html' } }), 'Slide file: The file is too large (limit 5 MiB).'],
290
+ ['415', () => problem(415, 'UNSUPPORTED_FILE_TYPE'), 'Slide file: This file type is not supported.'],
291
+ ['422 EMPTY_FILE', () => problem(422, 'EMPTY_FILE'), 'Slide file: The file is empty.'],
292
+ ])('%s is shown on the file field', async (_n, respond, text) => {
293
+ await submitValid(respond);
294
+ expect(await screen.findByRole('link', { name: text })).toBeInTheDocument();
295
+ });
296
+
297
+ it('403 SUBMISSION_CLOSED is a form message and refetches the deadline, which disables the button', async () => {
298
+ await submitValid(() => {
299
+ server.use(http.get(`${API}/deadline`, () => HttpResponse.json({ deadline: null, state: 'CLOSED', serverTime: '2026-11-08T00:00:00.000Z' })));
300
+ return problem(403, 'SUBMISSION_CLOSED');
301
+ });
302
+ const summary = await screen.findByRole('alert');
303
+ expect(summary).toHaveTextContent('Submissions are closed.');
304
+ expect(summary).toHaveTextContent('Request ID: 6f1c2c1e-0000-4000-8000-000000000000');
305
+ await waitFor(() => expect(screen.getByRole('button', { name: 'Submit Idea' })).toBeDisabled());
306
+ expect(screen.getByText('Submissions are not open, so the Idea cannot be submitted now.')).toBeInTheDocument();
307
+ });
308
+ });