@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,680 @@
1
+ //go:build integration
2
+
3
+ package idea_test
4
+
5
+ import (
6
+ "archive/zip"
7
+ "bytes"
8
+ "context"
9
+ "crypto/sha256"
10
+ "database/sql"
11
+ "encoding/hex"
12
+ "encoding/json"
13
+ "fmt"
14
+ "io"
15
+ "mime/multipart"
16
+ "net/http"
17
+ "net/http/httptest"
18
+ "strings"
19
+ "sync"
20
+ "testing"
21
+
22
+ "github.com/go-chi/chi/v5"
23
+ "golang.org/x/text/unicode/norm"
24
+
25
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/audit"
26
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/deadline"
27
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/idea"
28
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/server"
29
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/shared/dbtest"
30
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/shared/security"
31
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/shared/web"
32
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/storage"
33
+ )
34
+
35
+ // fakeObjects is an in-memory storage.SlideStore. before runs inside Put, so a
36
+ // test can change the database between the pre-checks and the transaction.
37
+ // deleteErr makes Delete fail (MinIO down after the upload). For Get: onGet
38
+ // runs first, getErr fails the open (MinIO down before the first byte), and
39
+ // a positive failAfter makes the stream fail with streamErr after that many
40
+ // bytes (MinIO down mid-stream).
41
+ type fakeObjects struct {
42
+ mu sync.Mutex
43
+ puts map[string][]byte
44
+ types map[string]string
45
+ fail error
46
+ deleteErr error
47
+ deleted []string
48
+ last string // key of the last successful Put
49
+ before func()
50
+
51
+ onGet func()
52
+ getErr error
53
+ failAfter int64
54
+ streamErr error
55
+ gets int
56
+ }
57
+
58
+ func newFakeObjects() *fakeObjects {
59
+ return &fakeObjects{puts: map[string][]byte{}, types: map[string]string{}}
60
+ }
61
+
62
+ func (f *fakeObjects) Put(_ context.Context, key string, r io.Reader, size int64, ct string) error {
63
+ if f.before != nil {
64
+ f.before()
65
+ }
66
+ if f.fail != nil {
67
+ return f.fail
68
+ }
69
+ b, err := io.ReadAll(r)
70
+ if err != nil {
71
+ return err
72
+ }
73
+ if int64(len(b)) != size {
74
+ return fmt.Errorf("size %d, read %d", size, len(b))
75
+ }
76
+ f.mu.Lock()
77
+ defer f.mu.Unlock()
78
+ f.puts[key], f.types[key] = b, ct
79
+ f.last = key
80
+ return nil
81
+ }
82
+
83
+ func (f *fakeObjects) Get(_ context.Context, key string) (io.ReadCloser, int64, error) {
84
+ if f.onGet != nil {
85
+ f.onGet()
86
+ }
87
+ f.mu.Lock()
88
+ defer f.mu.Unlock()
89
+ f.gets++
90
+ if f.getErr != nil {
91
+ return nil, 0, f.getErr
92
+ }
93
+ b, ok := f.puts[key]
94
+ if !ok {
95
+ return nil, 0, storage.ErrObjectNotFound
96
+ }
97
+ var r io.Reader = bytes.NewReader(b)
98
+ if f.failAfter > 0 {
99
+ r = io.MultiReader(io.LimitReader(r, f.failAfter), failingReader{f.streamErr})
100
+ }
101
+ return io.NopCloser(r), int64(len(b)), nil
102
+ }
103
+
104
+ type failingReader struct{ err error }
105
+
106
+ func (r failingReader) Read([]byte) (int, error) { return 0, r.err }
107
+
108
+ func (f *fakeObjects) Delete(_ context.Context, key string) error {
109
+ f.mu.Lock()
110
+ defer f.mu.Unlock()
111
+ if f.deleteErr != nil {
112
+ return f.deleteErr
113
+ }
114
+ delete(f.puts, key)
115
+ f.deleted = append(f.deleted, key)
116
+ return nil
117
+ }
118
+
119
+ func (f *fakeObjects) count() int {
120
+ f.mu.Lock()
121
+ defer f.mu.Unlock()
122
+ return len(f.puts)
123
+ }
124
+
125
+ type createEnv struct {
126
+ db *sql.DB
127
+ objects *fakeObjects
128
+ h idea.Handler
129
+ }
130
+
131
+ func newCreateEnv(t *testing.T) *createEnv {
132
+ t.Helper()
133
+ d := dbtest.New(t)
134
+ return newCreateEnvOn(t, d, d)
135
+ }
136
+
137
+ // newCreateEnvOn builds the env with the service's transactions on svcDB (for
138
+ // example a fault-injecting pool) and every other access on d.
139
+ func newCreateEnvOn(t *testing.T, d, svcDB *sql.DB) *createEnv {
140
+ t.Helper()
141
+ aud := audit.New(d, nil)
142
+ objs := newFakeObjects()
143
+ errs := server.ErrorAdapter{Audit: aud}
144
+ e := &createEnv{db: d, objects: objs, h: idea.Handler{
145
+ Service: &idea.Service{
146
+ DB: svcDB, Audit: aud,
147
+ Gate: deadline.Gate{Store: deadline.SQLStore{DB: d}},
148
+ Slides: server.IdeaSlideRules(),
149
+ Objects: objs,
150
+ Aborter: storage.NewPendingDeletion(d, objs, nil),
151
+ },
152
+ ExpectedFilename: storage.ExpectedFilenameBase,
153
+ Fail: errs.Write,
154
+ }}
155
+ e.setDeadline(t, "NOW(6) + INTERVAL 1 DAY")
156
+ return e
157
+ }
158
+
159
+ func (e *createEnv) setDeadline(t *testing.T, expr string) {
160
+ t.Helper()
161
+ if _, err := e.db.Exec(`UPDATE contest_setting SET deadline = ` + expr + ` WHERE id = 1`); err != nil {
162
+ t.Fatal(err)
163
+ }
164
+ }
165
+
166
+ func (e *createEnv) count(t *testing.T, q string, args ...any) (n int) {
167
+ t.Helper()
168
+ if err := e.db.QueryRow(q, args...).Scan(&n); err != nil {
169
+ t.Fatal(err)
170
+ }
171
+ return n
172
+ }
173
+
174
+ type form struct {
175
+ fields [][2]string // in order; a repeated name repeats the part
176
+ filename string
177
+ file []byte
178
+ noFile bool
179
+ }
180
+
181
+ func pdf(n int) []byte {
182
+ b := append([]byte("%PDF-1.7\n"), bytes.Repeat([]byte("x"), n)...)
183
+ return b
184
+ }
185
+
186
+ func pptx(t *testing.T) []byte {
187
+ t.Helper()
188
+ var buf bytes.Buffer
189
+ zw := zip.NewWriter(&buf)
190
+ for _, n := range []string{"[Content_Types].xml", "ppt/presentation.xml"} {
191
+ w, err := zw.Create(n)
192
+ if err != nil {
193
+ t.Fatal(err)
194
+ }
195
+ _, _ = w.Write([]byte("<x/>"))
196
+ }
197
+ if err := zw.Close(); err != nil {
198
+ t.Fatal(err)
199
+ }
200
+ return buf.Bytes()
201
+ }
202
+
203
+ // validForm is a valid create for leader 100001 ("User 100001").
204
+ func validForm(name string) form {
205
+ return form{
206
+ fields: [][2]string{
207
+ {"category", "COOL_IDEA"}, {"org", "BSID"}, {"direction", "TALENT_DEVELOPMENT"},
208
+ {"name", name}, {"summary", "line one\r\nline two"},
209
+ },
210
+ filename: "I_BSID_User 100001_100001_" + name + ".pdf",
211
+ file: pdf(100),
212
+ }
213
+ }
214
+
215
+ func (f form) set(name, value string) form {
216
+ out := f
217
+ out.fields = nil
218
+ for _, kv := range f.fields {
219
+ if kv[0] == name {
220
+ kv[1] = value
221
+ }
222
+ out.fields = append(out.fields, kv)
223
+ }
224
+ return out
225
+ }
226
+
227
+ func (f form) body(t *testing.T) (*bytes.Buffer, string) {
228
+ t.Helper()
229
+ var buf bytes.Buffer
230
+ mw := multipart.NewWriter(&buf)
231
+ for _, kv := range f.fields {
232
+ if err := mw.WriteField(kv[0], kv[1]); err != nil {
233
+ t.Fatal(err)
234
+ }
235
+ }
236
+ if !f.noFile {
237
+ h := make(map[string][]string)
238
+ h["Content-Disposition"] = []string{fmt.Sprintf(`form-data; name="slides"; filename="%s"`, f.filename)}
239
+ h["Content-Type"] = []string{"application/octet-stream"}
240
+ w, err := mw.CreatePart(h)
241
+ if err != nil {
242
+ t.Fatal(err)
243
+ }
244
+ _, _ = w.Write(f.file)
245
+ }
246
+ if err := mw.Close(); err != nil {
247
+ t.Fatal(err)
248
+ }
249
+ return &buf, mw.FormDataContentType()
250
+ }
251
+
252
+ func (e *createEnv) post(t *testing.T, p security.Principal, f form) *httptest.ResponseRecorder {
253
+ t.Helper()
254
+ body, ct := f.body(t)
255
+ r := chi.NewRouter()
256
+ r.Post("/ideas", e.h.CreateIdea)
257
+ req := httptest.NewRequest(http.MethodPost, "/ideas", body)
258
+ req.Header.Set("Content-Type", ct)
259
+ req = req.WithContext(security.WithPrincipal(context.Background(), p))
260
+ rec := httptest.NewRecorder()
261
+ r.ServeHTTP(rec, req)
262
+ return rec
263
+ }
264
+
265
+ type problem struct {
266
+ Code string
267
+ Errors []web.FieldError
268
+ }
269
+
270
+ func decodeProblem(t *testing.T, rec *httptest.ResponseRecorder) problem {
271
+ t.Helper()
272
+ var p problem
273
+ if err := json.Unmarshal(rec.Body.Bytes(), &p); err != nil {
274
+ t.Fatalf("problem body: %v (%s)", err, rec.Body)
275
+ }
276
+ return p
277
+ }
278
+
279
+ func TestCreateIdeaSuccess(t *testing.T) {
280
+ e := newCreateEnv(t)
281
+ leader := addUser(t, e.db, "100001", "USER")
282
+ f := validForm("Idea A")
283
+ f.fields = append(f.fields, [2]string{"leaderEmpNo", "999999"}) // ignored (PRD 6.6)
284
+
285
+ rec := e.post(t, leader, f)
286
+ if rec.Code != http.StatusCreated {
287
+ t.Fatalf("status %d: %s", rec.Code, rec.Body)
288
+ }
289
+ var body struct {
290
+ ID string
291
+ Name, Summary string
292
+ Leader struct{ EmpNo, Name string }
293
+ PendingRequests []any
294
+ ExpectedFilenameBase string
295
+ Version int
296
+ Slides struct {
297
+ Ext, SHA256, DownloadFilename string
298
+ Size int64
299
+ }
300
+ }
301
+ if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
302
+ t.Fatal(err)
303
+ }
304
+ if rec.Header().Get("Location") != "/api/v1/ideas/"+body.ID {
305
+ t.Errorf("Location %q, id %s", rec.Header().Get("Location"), body.ID)
306
+ }
307
+ sum := sha256.Sum256(f.file)
308
+ if body.Name != "Idea A" || body.Summary != "line one\nline two" || body.Leader.EmpNo != "100001" ||
309
+ body.Leader.Name != "User 100001" || body.PendingRequests == nil || body.Version != 0 ||
310
+ body.ExpectedFilenameBase != "I_BSID_User 100001_100001_Idea A" ||
311
+ body.Slides.Ext != "pdf" || body.Slides.Size != int64(len(f.file)) || body.Slides.SHA256 != hex.EncodeToString(sum[:]) ||
312
+ body.Slides.DownloadFilename != "I_BSID_User 100001_100001_Idea A.pdf" {
313
+ t.Errorf("body %+v", body)
314
+ }
315
+
316
+ var key string
317
+ if err := e.db.QueryRow(`SELECT slides_object_key FROM idea WHERE id = ?`, body.ID).Scan(&key); err != nil {
318
+ t.Fatal(err)
319
+ }
320
+ if !strings.HasPrefix(key, "slides/"+body.ID+"/") || !strings.HasSuffix(key, ".pdf") {
321
+ t.Errorf("object key %q", key)
322
+ }
323
+ if !bytes.Equal(e.objects.puts[key], f.file) || e.objects.types[key] != "application/pdf" {
324
+ t.Errorf("stored object %q: %d bytes, type %q", key, len(e.objects.puts[key]), e.objects.types[key])
325
+ }
326
+
327
+ var after string
328
+ if err := e.db.QueryRow(`SELECT after_json FROM audit_log WHERE action = 'IDEA_CREATE' AND outcome = 'SUCCESS'
329
+ AND target_type = 'IDEA' AND target_id = ? AND status = 201 AND route = '/ideas' AND actor_emp_no = '100001'`,
330
+ body.ID).Scan(&after); err != nil {
331
+ t.Fatalf("IDEA_CREATE row: %v", err)
332
+ }
333
+ var a map[string]any
334
+ _ = json.Unmarshal([]byte(after), &a)
335
+ sl, _ := a["slides"].(map[string]any)
336
+ if a["name"] != "Idea A" || a["category"] != "COOL_IDEA" || sl["sha256"] != hex.EncodeToString(sum[:]) || sl["ext"] != "pdf" {
337
+ t.Errorf("after_json %s", after)
338
+ }
339
+ }
340
+
341
+ func TestCreateIdeaPPTXAndNFDFilename(t *testing.T) {
342
+ e := newCreateEnv(t)
343
+ if _, err := e.db.Exec(`INSERT INTO app_user (id, emp_no, name, email, password_hash, role) VALUES (UUID(),'100001',?,?,?, 'USER')`,
344
+ "王小明", "100001@example.com", hash); err != nil {
345
+ t.Fatal(err)
346
+ }
347
+ var id string
348
+ if err := e.db.QueryRow(`SELECT id FROM app_user WHERE emp_no = '100001'`).Scan(&id); err != nil {
349
+ t.Fatal(err)
350
+ }
351
+ p := addUserPrincipal(t, id, "100001")
352
+ f := validForm("成立供應商永續學院").set("category", "OUTSTANDING_IMPACT")
353
+ f.filename = norm.NFD.String("O_BSID_王小明_100001_成立供應商永續學院.PPTX")
354
+ f.file = pptx(t)
355
+ rec := e.post(t, p, f)
356
+ if rec.Code != http.StatusCreated {
357
+ t.Fatalf("status %d: %s", rec.Code, rec.Body)
358
+ }
359
+ if !strings.Contains(rec.Body.String(), `"ext":"pptx"`) {
360
+ t.Errorf("body %s", rec.Body)
361
+ }
362
+ }
363
+
364
+ func addUserPrincipal(t *testing.T, id, empNo string) security.Principal {
365
+ t.Helper()
366
+ p := security.Principal{EmpNo: empNo, Role: security.RoleUser}
367
+ if err := p.UserID.UnmarshalText([]byte(id)); err != nil {
368
+ t.Fatal(err)
369
+ }
370
+ return p
371
+ }
372
+
373
+ func TestCreateIdeaRefusedBeforeBody(t *testing.T) {
374
+ e := newCreateEnv(t)
375
+ user := addUser(t, e.db, "100001", "USER")
376
+ admin := addUser(t, e.db, "900001", "ADMIN")
377
+ broken := form{noFile: true} // would be 400 if the body were read
378
+
379
+ rec := e.post(t, admin, broken)
380
+ if rec.Code != http.StatusForbidden || decodeProblem(t, rec).Code != "FORBIDDEN" {
381
+ t.Fatalf("admin: %d %s", rec.Code, rec.Body)
382
+ }
383
+ e.setDeadline(t, "NULL")
384
+ rec = e.post(t, user, broken)
385
+ if rec.Code != http.StatusForbidden || decodeProblem(t, rec).Code != "SUBMISSION_NOT_OPEN" {
386
+ t.Fatalf("not open: %d %s", rec.Code, rec.Body)
387
+ }
388
+ e.setDeadline(t, "NOW(6) - INTERVAL 1 SECOND")
389
+ rec = e.post(t, user, broken)
390
+ if rec.Code != http.StatusForbidden || decodeProblem(t, rec).Code != "SUBMISSION_CLOSED" {
391
+ t.Fatalf("closed: %d %s", rec.Code, rec.Body)
392
+ }
393
+ if got := e.count(t, `SELECT COUNT(*) FROM audit_log WHERE action = 'IDEA_CREATE' AND outcome = 'DENIED' AND status = 403`); got != 3 {
394
+ t.Errorf("DENIED rows = %d, want 3", got)
395
+ }
396
+ if e.count(t, `SELECT COUNT(*) FROM audit_log WHERE action = 'IDEA_CREATE' AND JSON_VALUE(after_json, '$.reason') = 'ROLE'`) != 1 ||
397
+ e.count(t, `SELECT COUNT(*) FROM audit_log WHERE action = 'IDEA_CREATE' AND JSON_VALUE(after_json, '$.reason') = 'STATE'`) != 2 {
398
+ t.Error("DENIED reasons: want one ROLE and two STATE")
399
+ }
400
+ }
401
+
402
+ func TestCreateIdeaValidation(t *testing.T) {
403
+ e := newCreateEnv(t)
404
+ user := addUser(t, e.db, "100001", "USER")
405
+ long := strings.Repeat("長", 75) // 225 bytes: valid length, filename too long
406
+
407
+ for _, tc := range []struct {
408
+ name string
409
+ form form
410
+ status int
411
+ code string
412
+ fields []web.FieldError
413
+ }{
414
+ {"missing file part", form{fields: validForm("x").fields, noFile: true}, 400, "MALFORMED_REQUEST", nil},
415
+ {"missing text part", func() form { f := validForm("x"); f.fields = f.fields[1:]; return f }(), 400, "MALFORMED_REQUEST", nil},
416
+ {"repeated text part", func() form { f := validForm("x"); f.fields = append(f.fields, f.fields[0]); return f }(), 400, "MALFORMED_REQUEST", nil},
417
+ {
418
+ // Every field reports its first error in form order; the (empty)
419
+ // file is not validated while text is invalid.
420
+ "text errors collected",
421
+ func() form {
422
+ f := validForm("x").set("category", "cool_idea").set("org", " ").set("direction", "GREEN").
423
+ set("name", "a/b").set("summary", "\r\n")
424
+ f.file = nil
425
+ return f
426
+ }(),
427
+ 422, "VALIDATION_FAILED",
428
+ []web.FieldError{
429
+ {Field: "category", Code: "INVALID_ENUM"}, {Field: "org", Code: "REQUIRED"},
430
+ {Field: "direction", Code: "INVALID_ENUM"}, {Field: "name", Code: "INVALID_NAME_CHARS"},
431
+ {Field: "summary", Code: "REQUIRED"},
432
+ },
433
+ },
434
+ {"name chars first", validForm("a:b"), 422, "INVALID_NAME_CHARS", []web.FieldError{{Field: "name", Code: "INVALID_NAME_CHARS"}}},
435
+ {"name too long", validForm(strings.Repeat("a", 76)), 422, "VALIDATION_FAILED", []web.FieldError{{Field: "name", Code: "OUT_OF_RANGE"}}},
436
+ {"summary too long", validForm("x").set("summary", strings.Repeat("s", 301)), 422, "VALIDATION_FAILED", []web.FieldError{{Field: "summary", Code: "OUT_OF_RANGE"}}},
437
+ {"filename too long", validForm(long), 422, "FILENAME_TOO_LONG", []web.FieldError{{Field: "name", Code: "FILENAME_TOO_LONG"}}},
438
+ {"empty file", func() form { f := validForm("x"); f.file = nil; return f }(), 422, "EMPTY_FILE", nil},
439
+ {"over 5 MiB", func() form { f := validForm("x"); f.file = pdf(5 * 1024 * 1024); return f }(), 413, "FILE_TOO_LARGE", nil},
440
+ {"magic mismatch", func() form { f := validForm("x"); f.file = []byte("not a pdf"); return f }(), 415, "UNSUPPORTED_FILE_TYPE", nil},
441
+ {
442
+ "filename mismatch carries expected", func() form { f := validForm("x"); f.filename = "slides.pdf"; return f }(),
443
+ 422, "FILENAME_MISMATCH",
444
+ []web.FieldError{{Field: "slides", Code: "FILENAME_MISMATCH", Expected: "I_BSID_User 100001_100001_x.pdf"}},
445
+ },
446
+ {
447
+ "path is not stripped", func() form { f := validForm("x"); f.filename = "dir/" + f.filename; return f }(),
448
+ 422, "FILENAME_MISMATCH",
449
+ []web.FieldError{{Field: "slides", Code: "FILENAME_MISMATCH", Expected: "I_BSID_User 100001_100001_x.pdf"}},
450
+ },
451
+ } {
452
+ t.Run(tc.name, func(t *testing.T) {
453
+ rec := e.post(t, user, tc.form)
454
+ p := decodeProblem(t, rec)
455
+ if rec.Code != tc.status || p.Code != tc.code {
456
+ t.Fatalf("got %d %s, want %d %s (%s)", rec.Code, p.Code, tc.status, tc.code, rec.Body)
457
+ }
458
+ if tc.fields != nil && fmt.Sprint(p.Errors) != fmt.Sprint(tc.fields) {
459
+ t.Errorf("errors %v, want %v", p.Errors, tc.fields)
460
+ }
461
+ })
462
+ }
463
+ if e.objects.count() != 0 || e.count(t, `SELECT COUNT(*) FROM idea`) != 0 {
464
+ t.Error("a rejected create must store nothing")
465
+ }
466
+ if e.count(t, `SELECT COUNT(*) FROM audit_log`) != 0 {
467
+ t.Error("400/413/415/422 are input errors and are not audited")
468
+ }
469
+ }
470
+
471
+ func TestCreateIdeaDuplicate(t *testing.T) {
472
+ e := newCreateEnv(t)
473
+ user := addUser(t, e.db, "100001", "USER")
474
+ other := addUser(t, e.db, "100002", "USER")
475
+
476
+ if rec := e.post(t, user, validForm("Same")); rec.Code != 201 {
477
+ t.Fatalf("first: %d %s", rec.Code, rec.Body)
478
+ }
479
+ // Pre-check: 409 and nothing uploaded.
480
+ rec := e.post(t, user, validForm("Same"))
481
+ if rec.Code != 409 || decodeProblem(t, rec).Code != "IDEA_DUPLICATE" || e.objects.count() != 1 {
482
+ t.Fatalf("pre-check: %d %s, %d objects", rec.Code, rec.Body, e.objects.count())
483
+ }
484
+ // Names are case-sensitive and unique per Leader only.
485
+ if rec := e.post(t, user, validForm("same")); rec.Code != 201 {
486
+ t.Errorf("case differs: %d %s", rec.Code, rec.Body)
487
+ }
488
+ f := validForm("Same")
489
+ f.filename = "I_BSID_User 100002_100002_Same.pdf"
490
+ if rec := e.post(t, other, f); rec.Code != 201 {
491
+ t.Errorf("other leader: %d %s", rec.Code, rec.Body)
492
+ }
493
+
494
+ // Race past the pre-check: the unique key answers 409 from the transaction.
495
+ e.objects.before = func() {
496
+ e.objects.before = nil
497
+ if err := insert(e.db, newIdea(user.UserID, "Raced")); err != nil {
498
+ t.Error(err)
499
+ }
500
+ }
501
+ rec = e.post(t, user, validForm("Raced"))
502
+ if rec.Code != 409 || decodeProblem(t, rec).Code != "IDEA_DUPLICATE" {
503
+ t.Fatalf("race: %d %s", rec.Code, rec.Body)
504
+ }
505
+ if e.count(t, `SELECT COUNT(*) FROM idea WHERE name = 'Raced'`) != 1 ||
506
+ e.count(t, `SELECT COUNT(*) FROM audit_log WHERE action = 'IDEA_CREATE' AND outcome = 'SUCCESS'`) != 3 {
507
+ t.Error("race: the losing create must leave no row and no audit row")
508
+ }
509
+ // Known rollback (B3-09): the losing upload is deleted at once, no row kept.
510
+ if len(e.objects.deleted) != 1 || e.objects.count() != 3 || e.pendingCount(t) != 0 {
511
+ t.Errorf("race: deleted %v, %d objects, %d pending rows", e.objects.deleted, e.objects.count(), e.pendingCount(t))
512
+ }
513
+ }
514
+
515
+ func TestCreateIdeaDeadlinePassesBeforeTx(t *testing.T) {
516
+ e := newCreateEnv(t)
517
+ user := addUser(t, e.db, "100001", "USER")
518
+ e.objects.before = func() { e.setDeadline(t, "NOW(6) - INTERVAL 1 SECOND") }
519
+
520
+ rec := e.post(t, user, validForm("Late"))
521
+ if rec.Code != 403 || decodeProblem(t, rec).Code != "SUBMISSION_CLOSED" {
522
+ t.Fatalf("%d %s", rec.Code, rec.Body)
523
+ }
524
+ if e.count(t, `SELECT COUNT(*) FROM idea`) != 0 {
525
+ t.Error("no row may exist")
526
+ }
527
+ if e.count(t, `SELECT COUNT(*) FROM audit_log WHERE action = 'IDEA_CREATE' AND outcome = 'DENIED'
528
+ AND error_code = 'SUBMISSION_CLOSED' AND JSON_VALUE(after_json, '$.reason') = 'STATE'`) != 1 {
529
+ t.Error("want one DENIED STATE row")
530
+ }
531
+ if len(e.objects.deleted) != 1 || e.objects.count() != 0 || e.pendingCount(t) != 0 {
532
+ t.Errorf("the rolled-back upload must be deleted: deleted %v, %d pending rows", e.objects.deleted, e.pendingCount(t))
533
+ }
534
+ }
535
+
536
+ // Known rollback while MinIO is down: the delete fails, so the key is
537
+ // recorded as UPLOAD_ABORTED, due at once; the response is the rollback's.
538
+ func TestCreateIdeaRollbackDeleteFails(t *testing.T) {
539
+ e := newCreateEnv(t)
540
+ user := addUser(t, e.db, "100001", "USER")
541
+ e.objects.deleteErr = web.NewError(web.CodeStorageUnavailable, "object storage is unavailable")
542
+ e.objects.before = func() { e.setDeadline(t, "NOW(6) - INTERVAL 1 SECOND") }
543
+
544
+ rec := e.post(t, user, validForm("Late"))
545
+ if rec.Code != 403 || decodeProblem(t, rec).Code != "SUBMISSION_CLOSED" {
546
+ t.Fatalf("%d %s", rec.Code, rec.Body)
547
+ }
548
+ if e.objects.count() != 1 {
549
+ t.Fatalf("%d objects, want the one kept upload", e.objects.count())
550
+ }
551
+ got := e.pending(t, e.objects.last)
552
+ if len(got) != 1 || got[0].reason != "UPLOAD_ABORTED" || got[0].dueInSec > 1 {
553
+ t.Errorf("pending rows %+v, want one UPLOAD_ABORTED due now", got)
554
+ }
555
+ }
556
+
557
+ // Lost-commit cases (Sequence.md §2, §3; Backend.md §4.4) through the
558
+ // fault-injecting connector: Unknown keeps the object, records it with the
559
+ // grace period and answers 503; a known rollback at COMMIT deletes it.
560
+ func TestCreateIdeaLostCommit(t *testing.T) {
561
+ dsn := dbtest.Start(t)
562
+ d := dbtest.Open(t, dsn)
563
+ faulty, faults := dbtest.OpenFaulty(t, dsn)
564
+ e := newCreateEnvOn(t, d, faulty)
565
+ user := addUser(t, d, "100001", "USER")
566
+
567
+ for i, tc := range []struct {
568
+ name string
569
+ fault dbtest.CommitFault
570
+ applied bool // the idea row exists after the COMMIT
571
+ kept bool // the object stays, recorded with the grace period
572
+ }{
573
+ {"commit applied, ack lost", dbtest.LostAckCommitted, true, true},
574
+ {"connection lost before commit", dbtest.LostAckNotCommitted, false, true},
575
+ {"deadlock at commit", dbtest.DeadlockAtCommit, false, false},
576
+ } {
577
+ t.Run(tc.name, func(t *testing.T) {
578
+ name := fmt.Sprintf("Lost %d", i)
579
+ before := len(e.objects.deleted)
580
+ faults.FailNextCommit(tc.fault)
581
+ f := validForm(name)
582
+ rec := e.post(t, user, f)
583
+ if rec.Code != 503 || decodeProblem(t, rec).Code != "SERVICE_UNAVAILABLE" {
584
+ t.Fatalf("%d %s", rec.Code, rec.Body)
585
+ }
586
+ key := e.objects.last
587
+ if n := e.count(t, `SELECT COUNT(*) FROM idea WHERE name = ?`, name); (n == 1) != tc.applied {
588
+ t.Errorf("idea rows = %d, applied %v", n, tc.applied)
589
+ }
590
+ _, stored := e.objects.puts[key]
591
+ got := e.pending(t, key)
592
+ if tc.kept {
593
+ grace := storage.UploadAbortedGrace.Seconds()
594
+ if !stored || len(got) != 1 || got[0].reason != "UPLOAD_ABORTED" ||
595
+ got[0].dueInSec < grace-30 || got[0].dueInSec > grace+1 {
596
+ t.Errorf("Unknown: stored %v, pending %+v; want kept and due after the grace", stored, got)
597
+ }
598
+ return
599
+ }
600
+ if stored || len(got) != 0 || len(e.objects.deleted) != before+1 {
601
+ t.Errorf("rollback: stored %v, pending %+v; want deleted now", stored, got)
602
+ }
603
+ })
604
+ }
605
+ if faults.Fired() != 3 {
606
+ t.Errorf("faults fired = %d, want 3", faults.Fired())
607
+ }
608
+ }
609
+
610
+ type pendingRow struct {
611
+ reason string
612
+ dueInSec float64 // next_attempt_at - NOW(6), database time
613
+ }
614
+
615
+ func (e *createEnv) pending(t *testing.T, key string) []pendingRow {
616
+ t.Helper()
617
+ rows, err := e.db.Query(`SELECT reason, TIMESTAMPDIFF(MICROSECOND, NOW(6), next_attempt_at) / 1e6
618
+ FROM pending_file_deletion WHERE object_key = ? ORDER BY id`, key)
619
+ if err != nil {
620
+ t.Fatal(err)
621
+ }
622
+ defer func() { _ = rows.Close() }()
623
+ var out []pendingRow
624
+ for rows.Next() {
625
+ var r pendingRow
626
+ if err := rows.Scan(&r.reason, &r.dueInSec); err != nil {
627
+ t.Fatal(err)
628
+ }
629
+ out = append(out, r)
630
+ }
631
+ if err := rows.Err(); err != nil {
632
+ t.Fatal(err)
633
+ }
634
+ return out
635
+ }
636
+
637
+ func (e *createEnv) pendingCount(t *testing.T) int {
638
+ t.Helper()
639
+ return e.count(t, `SELECT COUNT(*) FROM pending_file_deletion`)
640
+ }
641
+
642
+ func TestCreateIdeaStorageDown(t *testing.T) {
643
+ e := newCreateEnv(t)
644
+ user := addUser(t, e.db, "100001", "USER")
645
+ e.objects.fail = web.NewError(web.CodeStorageUnavailable, "object storage is unavailable")
646
+
647
+ rec := e.post(t, user, validForm("Idea A"))
648
+ if rec.Code != 503 || decodeProblem(t, rec).Code != "STORAGE_UNAVAILABLE" {
649
+ t.Fatalf("%d %s", rec.Code, rec.Body)
650
+ }
651
+ if e.count(t, `SELECT COUNT(*) FROM idea`) != 0 || e.count(t, `SELECT COUNT(*) FROM audit_log`) != 0 {
652
+ t.Error("no row and no audit row may exist")
653
+ }
654
+ }
655
+
656
+ // A declared length over the limit is answered before the body is read; the
657
+ // 5 MiB + 64 KiB limit leaves room for the text parts of a 5 MiB file.
658
+ func TestCreateIdeaDeclaredLengthTooLarge(t *testing.T) {
659
+ e := newCreateEnv(t)
660
+ user := addUser(t, e.db, "100001", "USER")
661
+ body, ct := validForm("x").body(t)
662
+ r := chi.NewRouter()
663
+ r.Post("/ideas", e.h.CreateIdea)
664
+ req := httptest.NewRequest(http.MethodPost, "/ideas", body)
665
+ req.Header.Set("Content-Type", ct)
666
+ req.ContentLength = 5*1024*1024 + 64*1024 + 1
667
+ req = req.WithContext(security.WithPrincipal(context.Background(), user))
668
+ rec := httptest.NewRecorder()
669
+ r.ServeHTTP(rec, req)
670
+ if rec.Code != 413 || decodeProblem(t, rec).Code != "FILE_TOO_LARGE" || e.objects.count() != 0 {
671
+ t.Fatalf("%d %s", rec.Code, rec.Body)
672
+ }
673
+
674
+ // Exactly 5 MiB of slides still fits.
675
+ f := validForm("Max")
676
+ f.file = pdf(5*1024*1024 - len("%PDF-1.7\n"))
677
+ if rec := e.post(t, user, f); rec.Code != 201 {
678
+ t.Fatalf("5 MiB file: %d %s", rec.Code, rec.Body)
679
+ }
680
+ }