@johnnyboy11234/esg-idea-contest 0.2.4 → 0.3.1

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