@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,324 @@
1
+ //go:build integration
2
+
3
+ package storage_test
4
+
5
+ import (
6
+ "bytes"
7
+ "context"
8
+ "database/sql"
9
+ "errors"
10
+ "io"
11
+ "log/slog"
12
+ "strings"
13
+ "sync"
14
+ "testing"
15
+
16
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/shared/db"
17
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/shared/dbtest"
18
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/storage"
19
+ )
20
+
21
+ // fakeStore records Delete calls; deleteErr makes them fail (MinIO down).
22
+ type fakeStore struct {
23
+ mu sync.Mutex
24
+ deleteErr error
25
+ deleted []string
26
+ ctxErr []error // ctx.Err() seen by each Delete
27
+ }
28
+
29
+ func (f *fakeStore) Put(context.Context, string, io.Reader, int64, string) error { return nil }
30
+ func (f *fakeStore) Get(context.Context, string) (io.ReadCloser, int64, error) {
31
+ return nil, 0, storage.ErrObjectNotFound
32
+ }
33
+
34
+ func (f *fakeStore) Delete(ctx context.Context, key string) error {
35
+ f.mu.Lock()
36
+ defer f.mu.Unlock()
37
+ f.ctxErr = append(f.ctxErr, ctx.Err())
38
+ if f.deleteErr != nil {
39
+ return f.deleteErr
40
+ }
41
+ f.deleted = append(f.deleted, key)
42
+ return nil
43
+ }
44
+
45
+ type pendingRow struct {
46
+ Reason string
47
+ DueInSec float64 // next_attempt_at - NOW(6), database time
48
+ Attempts int
49
+ }
50
+
51
+ func pendingRows(t *testing.T, d *sql.DB, key string) []pendingRow {
52
+ t.Helper()
53
+ rows, err := d.Query(`SELECT reason, TIMESTAMPDIFF(MICROSECOND, NOW(6), next_attempt_at) / 1e6, attempts
54
+ FROM pending_file_deletion WHERE object_key = ? ORDER BY id`, key)
55
+ if err != nil {
56
+ t.Fatal(err)
57
+ }
58
+ defer func() { _ = rows.Close() }()
59
+ var out []pendingRow
60
+ for rows.Next() {
61
+ var r pendingRow
62
+ if err := rows.Scan(&r.Reason, &r.DueInSec, &r.Attempts); err != nil {
63
+ t.Fatal(err)
64
+ }
65
+ out = append(out, r)
66
+ }
67
+ if err := rows.Err(); err != nil {
68
+ t.Fatal(err)
69
+ }
70
+ return out
71
+ }
72
+
73
+ func newLogger() (*slog.Logger, *bytes.Buffer) {
74
+ var buf bytes.Buffer
75
+ return slog.New(slog.NewJSONHandler(&buf, nil)), &buf
76
+ }
77
+
78
+ func TestPendingDeletion(t *testing.T) {
79
+ dsn := dbtest.Start(t)
80
+ d := dbtest.Open(t, dsn)
81
+ ctx := context.Background()
82
+
83
+ recordInTx := func(t *testing.T, p *storage.PendingDeletion, key string, r storage.Reason) storage.PendingID {
84
+ t.Helper()
85
+ var id storage.PendingID
86
+ out, err := db.InTx(ctx, d, func(ctx context.Context, tx *sql.Tx) error {
87
+ var err error
88
+ id, err = p.RecordInTx(ctx, tx, key, r)
89
+ return err
90
+ })
91
+ if out != db.Committed || err != nil {
92
+ t.Fatalf("InTx = %v, %v", out, err)
93
+ }
94
+ return id
95
+ }
96
+
97
+ t.Run("RecordInTx writes REPLACED / DELETED due now, only when the tx commits", func(t *testing.T) {
98
+ p := storage.NewPendingDeletion(d, &fakeStore{}, nil)
99
+ for _, r := range []storage.Reason{storage.ReasonReplaced, storage.ReasonDeleted} {
100
+ key := "slides/in-tx/" + string(r) + ".pdf"
101
+ if id := recordInTx(t, p, key, r); id <= 0 {
102
+ t.Errorf("%s: id = %d", r, id)
103
+ }
104
+ got := pendingRows(t, d, key)
105
+ if len(got) != 1 || got[0].Reason != string(r) || got[0].DueInSec > 0 || got[0].Attempts != 0 {
106
+ t.Errorf("%s: rows = %+v, want one due row", r, got)
107
+ }
108
+ }
109
+
110
+ key := "slides/in-tx/rolled-back.pdf"
111
+ out, _ := db.InTx(ctx, d, func(ctx context.Context, tx *sql.Tx) error {
112
+ if _, err := p.RecordInTx(ctx, tx, key, storage.ReasonReplaced); err != nil {
113
+ return err
114
+ }
115
+ return errors.New("later step fails")
116
+ })
117
+ if out != db.RolledBack {
118
+ t.Fatalf("outcome = %v", out)
119
+ }
120
+ if got := pendingRows(t, d, key); len(got) != 0 {
121
+ t.Errorf("rolled-back tx left %+v", got)
122
+ }
123
+ })
124
+
125
+ t.Run("RecordUploadAborted: known rollback due now, unknown after the grace period", func(t *testing.T) {
126
+ p := storage.NewPendingDeletion(d, &fakeStore{}, nil)
127
+ if err := p.RecordUploadAborted(ctx, "slides/aborted/rb.pdf", db.RolledBack); err != nil {
128
+ t.Fatal(err)
129
+ }
130
+ if got := pendingRows(t, d, "slides/aborted/rb.pdf"); len(got) != 1 ||
131
+ got[0].Reason != "UPLOAD_ABORTED" || got[0].DueInSec > 0 {
132
+ t.Errorf("rows = %+v", got)
133
+ }
134
+
135
+ if err := p.RecordUploadAborted(ctx, "slides/aborted/unknown.pdf", db.Unknown); err != nil {
136
+ t.Fatal(err)
137
+ }
138
+ got := pendingRows(t, d, "slides/aborted/unknown.pdf")
139
+ grace := storage.UploadAbortedGrace.Seconds()
140
+ if len(got) != 1 || got[0].Reason != "UPLOAD_ABORTED" || got[0].DueInSec > grace || got[0].DueInSec < grace-60 {
141
+ t.Errorf("rows = %+v, want due in about %v s", got, grace)
142
+ }
143
+ })
144
+
145
+ t.Run("RecordUploadAborted survives a cancelled request context", func(t *testing.T) {
146
+ p := storage.NewPendingDeletion(d, &fakeStore{}, nil)
147
+ cctx, cancel := context.WithCancel(ctx)
148
+ cancel()
149
+ if err := p.RecordUploadAborted(cctx, "slides/aborted/cancelled.pdf", db.Unknown); err != nil {
150
+ t.Fatal(err)
151
+ }
152
+ if got := pendingRows(t, d, "slides/aborted/cancelled.pdf"); len(got) != 1 {
153
+ t.Errorf("rows = %+v", got)
154
+ }
155
+ })
156
+
157
+ t.Run("a key may be recorded more than once", func(t *testing.T) {
158
+ p := storage.NewPendingDeletion(d, &fakeStore{}, nil)
159
+ key := "slides/twice/k.pdf"
160
+ if err := p.RecordUploadAborted(ctx, key, db.Unknown); err != nil {
161
+ t.Fatal(err)
162
+ }
163
+ recordInTx(t, p, key, storage.ReasonReplaced)
164
+ if got := pendingRows(t, d, key); len(got) != 2 {
165
+ t.Errorf("rows = %+v, want 2", got)
166
+ }
167
+ })
168
+
169
+ t.Run("DeleteAfterCommit deletes the object, then the row, even after a client disconnect", func(t *testing.T) {
170
+ fs := &fakeStore{}
171
+ p := storage.NewPendingDeletion(d, fs, nil)
172
+ key := "slides/post-commit/ok.pdf"
173
+ id := recordInTx(t, p, key, storage.ReasonDeleted)
174
+ cctx, cancel := context.WithCancel(ctx)
175
+ cancel()
176
+ p.DeleteAfterCommit(cctx, id, key)
177
+ if len(fs.deleted) != 1 || fs.deleted[0] != key || fs.ctxErr[0] != nil {
178
+ t.Errorf("deleted = %v, ctxErr = %v", fs.deleted, fs.ctxErr)
179
+ }
180
+ if got := pendingRows(t, d, key); len(got) != 0 {
181
+ t.Errorf("row not removed: %+v", got)
182
+ }
183
+ })
184
+
185
+ t.Run("DeleteAfterCommit with MinIO down keeps the row and logs", func(t *testing.T) {
186
+ log, buf := newLogger()
187
+ fs := &fakeStore{deleteErr: errors.New("connection refused")}
188
+ p := storage.NewPendingDeletion(d, fs, log)
189
+ key := "slides/post-commit/down.pdf"
190
+ id := recordInTx(t, p, key, storage.ReasonReplaced)
191
+ p.DeleteAfterCommit(ctx, id, key)
192
+ if got := pendingRows(t, d, key); len(got) != 1 || got[0].Reason != "REPLACED" {
193
+ t.Errorf("rows = %+v, want the REPLACED row kept", got)
194
+ }
195
+ if !strings.Contains(buf.String(), "left for the clean-up job") || !strings.Contains(buf.String(), key) {
196
+ t.Errorf("log = %s", buf.String())
197
+ }
198
+ })
199
+
200
+ t.Run("database failures are logged, not panicked", func(t *testing.T) {
201
+ closed := dbtest.Open(t, dsn)
202
+ _ = closed.Close()
203
+ log, buf := newLogger()
204
+ fs := &fakeStore{}
205
+ p := storage.NewPendingDeletion(closed, fs, log)
206
+
207
+ if err := p.RecordUploadAborted(ctx, "slides/closed/a.pdf", db.Unknown); err == nil {
208
+ t.Error("RecordUploadAborted on a closed pool: want error")
209
+ }
210
+ p.DeleteAfterCommit(ctx, 1, "slides/closed/b.pdf")
211
+ if len(fs.deleted) != 1 {
212
+ t.Errorf("object delete must still be attempted: %v", fs.deleted)
213
+ }
214
+ for _, want := range []string{"may be orphaned", "row not removed"} {
215
+ if !strings.Contains(buf.String(), want) {
216
+ t.Errorf("log lacks %q: %s", want, buf.String())
217
+ }
218
+ }
219
+ })
220
+ }
221
+
222
+ // Lost-commit cases (Sequence.md §2, §7): InTx through the fault-injecting
223
+ // connector, then the compensation a caller makes for the new key.
224
+ func TestPendingDeletionAroundLostCommit(t *testing.T) {
225
+ dsn := dbtest.Start(t)
226
+ d := dbtest.Open(t, dsn)
227
+ faulty, faults := dbtest.OpenFaulty(t, dsn)
228
+ ctx := context.Background()
229
+ p := storage.NewPendingDeletion(d, &fakeStore{}, nil)
230
+
231
+ for _, tc := range []struct {
232
+ name string
233
+ fault dbtest.CommitFault
234
+ want db.Outcome
235
+ oldKeyRows int // REPLACED rows for the old key: present iff the commit applied
236
+ }{
237
+ {"commit applied, ack lost", dbtest.LostAckCommitted, db.Unknown, 1},
238
+ {"connection lost before commit", dbtest.LostAckNotCommitted, db.Unknown, 0},
239
+ {"deadlock at commit", dbtest.DeadlockAtCommit, db.RolledBack, 0},
240
+ } {
241
+ t.Run(tc.name, func(t *testing.T) {
242
+ oldKey := "slides/lost/" + tc.name + "/old.pdf"
243
+ newKey := "slides/lost/" + tc.name + "/new.pdf"
244
+ faults.FailNextCommit(tc.fault)
245
+ out, err := db.InTx(ctx, faulty, func(ctx context.Context, tx *sql.Tx) error {
246
+ _, err := p.RecordInTx(ctx, tx, oldKey, storage.ReasonReplaced)
247
+ return err
248
+ })
249
+ if out != tc.want || err == nil {
250
+ t.Fatalf("InTx = %v, %v; want %v with an error", out, err, tc.want)
251
+ }
252
+ if got := pendingRows(t, d, oldKey); len(got) != tc.oldKeyRows {
253
+ t.Errorf("old key rows = %+v, want %d", got, tc.oldKeyRows)
254
+ }
255
+ // The caller never deletes the new object on Unknown; it records it.
256
+ if err := p.RecordUploadAborted(ctx, newKey, out); err != nil {
257
+ t.Fatal(err)
258
+ }
259
+ got := pendingRows(t, d, newKey)
260
+ if len(got) != 1 || (out == db.Unknown) != (got[0].DueInSec > 0) {
261
+ t.Errorf("new key rows = %+v (outcome %v)", got, out)
262
+ }
263
+ })
264
+ }
265
+ if faults.Fired() != 3 {
266
+ t.Errorf("faults fired = %d, want 3", faults.Fired())
267
+ }
268
+
269
+ // The pool stays usable after the injected failures.
270
+ if out, err := db.InTx(ctx, faulty, func(context.Context, *sql.Tx) error { return nil }); out != db.Committed || err != nil {
271
+ t.Errorf("after faults: %v, %v", out, err)
272
+ }
273
+ }
274
+
275
+ // AbortUpload (Sequence.md §2): known rollback deletes now and records only
276
+ // when that fails; Unknown never deletes and records with the grace period.
277
+ // A cancelled request context does not skip the compensation.
278
+ func TestAbortUpload(t *testing.T) {
279
+ d := dbtest.New(t)
280
+ gone, cancel := context.WithCancel(context.Background())
281
+ cancel()
282
+
283
+ for _, tc := range []struct {
284
+ name string
285
+ outcome db.Outcome
286
+ deleteErr error
287
+ deleted bool
288
+ rows int
289
+ graced bool
290
+ }{
291
+ {"rolled back", db.RolledBack, nil, true, 0, false},
292
+ {"rolled back, delete fails", db.RolledBack, errors.New("minio down"), false, 1, false},
293
+ {"unknown", db.Unknown, nil, false, 1, true},
294
+ {"committed is ignored", db.Committed, nil, false, 0, false},
295
+ } {
296
+ t.Run(tc.name, func(t *testing.T) {
297
+ fs := &fakeStore{deleteErr: tc.deleteErr}
298
+ p := storage.NewPendingDeletion(d, fs, nil)
299
+ key := "slides/abort/" + strings.ReplaceAll(tc.name, " ", "-") + ".pdf"
300
+ p.AbortUpload(gone, key, tc.outcome)
301
+
302
+ if got := len(fs.deleted) == 1; got != tc.deleted {
303
+ t.Errorf("deleted = %v, want %v", fs.deleted, tc.deleted)
304
+ }
305
+ for _, err := range fs.ctxErr {
306
+ if err != nil {
307
+ t.Errorf("Delete saw ctx error %v; the request's cancellation must be ignored", err)
308
+ }
309
+ }
310
+ if tc.outcome == db.Unknown && len(fs.ctxErr) != 0 {
311
+ t.Error("Unknown must never call Delete")
312
+ }
313
+ got := pendingRows(t, d, key)
314
+ if len(got) != tc.rows {
315
+ t.Fatalf("rows = %+v, want %d", got, tc.rows)
316
+ }
317
+ if tc.rows == 1 {
318
+ if got[0].Reason != string(storage.ReasonUploadAborted) || (got[0].DueInSec > 1) != tc.graced {
319
+ t.Errorf("row = %+v, graced %v", got[0], tc.graced)
320
+ }
321
+ }
322
+ })
323
+ }
324
+ }
@@ -0,0 +1,23 @@
1
+ package storage
2
+
3
+ import (
4
+ "context"
5
+ "testing"
6
+
7
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/shared/db"
8
+ )
9
+
10
+ // Misuse is rejected before any database call (nil pool on purpose).
11
+ func TestPendingDeletionRejectsMisuse(t *testing.T) {
12
+ p := NewPendingDeletion(nil, nil, nil)
13
+ ctx := context.Background()
14
+ if _, err := p.RecordInTx(ctx, nil, "k", ReasonUploadAborted); err == nil {
15
+ t.Error("RecordInTx(UPLOAD_ABORTED): want error, the key is recorded outside the tx")
16
+ }
17
+ if _, err := p.RecordInTx(ctx, nil, "k", Reason("OTHER")); err == nil {
18
+ t.Error("RecordInTx(OTHER): want error")
19
+ }
20
+ if err := p.RecordUploadAborted(ctx, "k", db.Committed); err == nil {
21
+ t.Error("RecordUploadAborted(Committed): want error, a committed key is referenced")
22
+ }
23
+ }
@@ -0,0 +1,100 @@
1
+ // Package storage holds the slide rules of PRD §6.8 that run before any object
2
+ // is written: SlideValidator, and later the MinIO port and ExpectedFilename.
3
+ package storage
4
+
5
+ import (
6
+ "archive/zip"
7
+ "bytes"
8
+ "io"
9
+ "strings"
10
+
11
+ "golang.org/x/text/unicode/norm"
12
+
13
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/shared/web"
14
+ )
15
+
16
+ // MaxSlideBytes is the exact size limit of one slide file (5 MiB).
17
+ const MaxSlideBytes int64 = 5 * 1024 * 1024
18
+
19
+ // Kind is the accepted slide type.
20
+ type Kind string
21
+
22
+ const (
23
+ PDF Kind = "pdf"
24
+ PPTX Kind = "pptx"
25
+ )
26
+
27
+ // Part is one file part named "slides" of the multipart request.
28
+ type Part struct {
29
+ Filename string // as sent, UTF-8, path components not stripped
30
+ Size int64
31
+ Data io.ReaderAt // the spooled part; only the head or central directory is read
32
+ }
33
+
34
+ // Validate runs PRD §6.8 steps 1–5 in order and stops at the first failure:
35
+ // exactly one part (400), size (413), not empty (422), magic bytes agree with
36
+ // the extension (415), expected filename (422). expectedBase is the expected
37
+ // name without extension. It reads no more than the PDF head or the ZIP
38
+ // central directory.
39
+ func Validate(parts []Part, expectedBase string) (Kind, error) {
40
+ if len(parts) != 1 {
41
+ return "", web.NewError(web.CodeMalformedRequest, "Exactly one slides file part is required.")
42
+ }
43
+ p := parts[0]
44
+ if p.Size > MaxSlideBytes {
45
+ return "", web.NewError(web.CodeFileTooLarge, "Slides exceed 5 MiB.")
46
+ }
47
+ if p.Size == 0 {
48
+ return "", web.NewError(web.CodeEmptyFile, "Slides file is empty.")
49
+ }
50
+ kind, ok := kindFromExtension(p.Filename)
51
+ if !ok || !magicMatches(kind, p) {
52
+ return "", web.NewError(web.CodeUnsupportedFileType, "Slides must be a PDF or PPTX file whose content matches its extension.")
53
+ }
54
+ want := expectedBase + "." + string(kind)
55
+ // The extension is ASCII, so the split point survives NFC; only the base is
56
+ // compared exactly, the extension case-insensitively (kindFromExtension).
57
+ got := norm.NFC.String(p.Filename)
58
+ if got[:len(got)-len(kind)-1] != expectedBase {
59
+ e := web.NewError(web.CodeFilenameMismatch, "Uploaded filename does not match the expected filename.")
60
+ e.Fields = []web.FieldError{{Field: "slides", Code: web.CodeFilenameMismatch, Expected: want}}
61
+ return "", e
62
+ }
63
+ return kind, nil
64
+ }
65
+
66
+ func kindFromExtension(name string) (Kind, bool) {
67
+ l := strings.ToLower(name)
68
+ switch {
69
+ case strings.HasSuffix(l, ".pdf"):
70
+ return PDF, true
71
+ case strings.HasSuffix(l, ".pptx"):
72
+ return PPTX, true
73
+ }
74
+ return "", false
75
+ }
76
+
77
+ func magicMatches(kind Kind, p Part) bool {
78
+ switch kind {
79
+ case PDF:
80
+ head := make([]byte, 5)
81
+ n, _ := p.Data.ReadAt(head, 0)
82
+ return n == 5 && bytes.Equal(head, []byte("%PDF-"))
83
+ case PPTX:
84
+ zr, err := zip.NewReader(p.Data, p.Size)
85
+ if err != nil {
86
+ return false
87
+ }
88
+ var ct, pres bool
89
+ for _, f := range zr.File {
90
+ switch f.Name {
91
+ case "[Content_Types].xml":
92
+ ct = true
93
+ case "ppt/presentation.xml":
94
+ pres = true
95
+ }
96
+ }
97
+ return ct && pres
98
+ }
99
+ return false
100
+ }
@@ -0,0 +1,95 @@
1
+ package storage
2
+
3
+ import (
4
+ "archive/zip"
5
+ "bytes"
6
+ "errors"
7
+ "strings"
8
+ "testing"
9
+
10
+ "golang.org/x/text/unicode/norm"
11
+
12
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/shared/web"
13
+ )
14
+
15
+ func zipOf(t *testing.T, names ...string) []byte {
16
+ t.Helper()
17
+ var b bytes.Buffer
18
+ w := zip.NewWriter(&b)
19
+ for _, n := range names {
20
+ f, err := w.Create(n)
21
+ if err != nil {
22
+ t.Fatal(err)
23
+ }
24
+ f.Write([]byte("x"))
25
+ }
26
+ if err := w.Close(); err != nil {
27
+ t.Fatal(err)
28
+ }
29
+ return b.Bytes()
30
+ }
31
+
32
+ func part(name string, data []byte) Part {
33
+ return Part{Filename: name, Size: int64(len(data)), Data: bytes.NewReader(data)}
34
+ }
35
+
36
+ func TestValidate(t *testing.T) {
37
+ const base = "I_BSID_王小明_089290_成立供應商永續學院"
38
+ pdf := []byte("%PDF-1.7 body")
39
+ pptx := zipOf(t, "[Content_Types].xml", "ppt/presentation.xml")
40
+ big := append([]byte("%PDF-"), make([]byte, MaxSlideBytes-5)...)
41
+ tooBig := append(big, 0)
42
+
43
+ tests := []struct {
44
+ name string
45
+ parts []Part
46
+ want Kind
47
+ code web.ErrorCode
48
+ wantExp string
49
+ }{
50
+ {"pdf ok", []Part{part(base+".pdf", pdf)}, PDF, "", ""},
51
+ {"pptx ok", []Part{part(base+".pptx", pptx)}, PPTX, "", ""},
52
+ {"extension upper case ok", []Part{part(base+".PDF", pdf)}, PDF, "", ""},
53
+ {"NFD filename ok", []Part{part(norm.NFD.String(base)+".pdf", pdf)}, PDF, "", ""},
54
+ {"exactly 5 MiB ok", []Part{part(base+".pdf", big)}, PDF, "", ""},
55
+ {"no part", nil, "", web.CodeMalformedRequest, ""},
56
+ {"two parts", []Part{part(base+".pdf", pdf), part(base+".pdf", pdf)}, "", web.CodeMalformedRequest, ""},
57
+ {"5 MiB + 1", []Part{part(base+".pdf", tooBig)}, "", web.CodeFileTooLarge, ""},
58
+ {"size beats type", []Part{part("x.txt", tooBig)}, "", web.CodeFileTooLarge, ""},
59
+ {"empty", []Part{part(base+".pdf", nil)}, "", web.CodeEmptyFile, ""},
60
+ {"empty beats type", []Part{part("x.txt", nil)}, "", web.CodeEmptyFile, ""},
61
+ {"unknown extension", []Part{part(base+".txt", pdf)}, "", web.CodeUnsupportedFileType, ""},
62
+ {"no extension", []Part{part(base, pdf)}, "", web.CodeUnsupportedFileType, ""},
63
+ {"pdf extension, pptx bytes", []Part{part(base+".pdf", pptx)}, "", web.CodeUnsupportedFileType, ""},
64
+ {"pptx extension, pdf bytes", []Part{part(base+".pptx", pdf)}, "", web.CodeUnsupportedFileType, ""},
65
+ {"pdf magic not at offset 0", []Part{part(base+".pdf", append([]byte(" "), pdf...))}, "", web.CodeUnsupportedFileType, ""},
66
+ {"short pdf", []Part{part(base+".pdf", []byte("%PDF"))}, "", web.CodeUnsupportedFileType, ""},
67
+ {"zip without presentation.xml", []Part{part(base+".pptx", zipOf(t, "[Content_Types].xml"))}, "", web.CodeUnsupportedFileType, ""},
68
+ {"zip without content types", []Part{part(base+".pptx", zipOf(t, "ppt/presentation.xml"))}, "", web.CodeUnsupportedFileType, ""},
69
+ {"docx is not pptx", []Part{part(base+".pptx", zipOf(t, "[Content_Types].xml", "word/document.xml"))}, "", web.CodeUnsupportedFileType, ""},
70
+ {"type beats filename", []Part{part("wrong.pdf", pptx)}, "", web.CodeUnsupportedFileType, ""},
71
+ {"wrong base", []Part{part("I_BSID_王小明_089290_其他.pdf", pdf)}, "", web.CodeFilenameMismatch, base + ".pdf"},
72
+ {"expected carries upload extension", []Part{part("wrong.PPTX", pptx)}, "", web.CodeFilenameMismatch, base + ".pptx"},
73
+ {"base is case sensitive", []Part{part(strings.ToLower(base)+".pdf", pdf)}, "", web.CodeFilenameMismatch, base + ".pdf"},
74
+ {"path is not stripped", []Part{part("dir/"+base+".pdf", pdf)}, "", web.CodeFilenameMismatch, base + ".pdf"},
75
+ {"surrounding space", []Part{part(" "+base+".pdf", pdf)}, "", web.CodeFilenameMismatch, base + ".pdf"},
76
+ }
77
+ for _, tc := range tests {
78
+ t.Run(tc.name, func(t *testing.T) {
79
+ got, err := Validate(tc.parts, base)
80
+ if tc.code == "" {
81
+ if err != nil || got != tc.want {
82
+ t.Fatalf("got (%q, %v), want (%q, nil)", got, err, tc.want)
83
+ }
84
+ return
85
+ }
86
+ var we *web.Error
87
+ if !errors.As(err, &we) || we.Code != tc.code {
88
+ t.Fatalf("got %v, want code %s", err, tc.code)
89
+ }
90
+ if tc.wantExp != "" && (len(we.Fields) != 1 || we.Fields[0].Expected != tc.wantExp || we.Fields[0].Field != "slides") {
91
+ t.Fatalf("fields = %+v, want expected %q", we.Fields, tc.wantExp)
92
+ }
93
+ })
94
+ }
95
+ }
@@ -36,6 +36,9 @@ const (
36
36
  // may import. A module directory missing from this table fails the test, so adding a module forces
37
37
  // a conscious decision about its edges. Facts it encodes:
38
38
  // - D29: storage has no dependency on idea; slides sits on idea + storage.
39
+ // - idea -> storage is allowed only for Idea delete (Sequence.md §5): idea inserts
40
+ // pending_file_deletion in its tx and deletes the object after commit. idea should depend on
41
+ // a small interface it declares itself, not on storage's concrete types (bd 1ve).
39
42
  // - D30: account never imports auth (SessionRevoker is injected from main.go).
40
43
  // - D53: audit is reached by business modules; audit itself imports no module.
41
44
  // - admin holds HTTP entry points only and may call any service.
@@ -45,7 +48,7 @@ var allowedEdges = map[string][]string{
45
48
  "account": {"audit"},
46
49
  "auth": {"account", "audit"},
47
50
  "storage": {"audit"},
48
- "idea": {"audit", "deadline"},
51
+ "idea": {"audit", "deadline", "storage"},
49
52
  "team": {"audit", "deadline", "idea"},
50
53
  "slides": {"audit", "deadline", "idea", "storage"},
51
54
  "admin": {"account", "audit", "auth", "deadline", "idea", "slides", "storage", "team"},
@@ -284,6 +287,7 @@ func TestChecksDetectViolations(t *testing.T) {
284
287
 
285
288
  allowed := []imp{
286
289
  {File: "a.go", Pkg: "slides", Target: "storage"},
290
+ {File: "a.go", Pkg: "idea", Target: "storage"},
287
291
  {File: "a.go", Pkg: "idea", Target: "shared/web"},
288
292
  {File: "a.go", Pkg: "idea", Target: "api"},
289
293
  {File: "a.go", Pkg: "server", Target: "idea"},
@@ -0,0 +1,60 @@
1
+ package contract
2
+
3
+ import (
4
+ "encoding/json"
5
+ "errors"
6
+ "os"
7
+ "testing"
8
+
9
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/shared/web"
10
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/storage"
11
+ )
12
+
13
+ func TestExpectedFilename(t *testing.T) {
14
+ raw, err := os.ReadFile("../../../testdata/expected-filename.json")
15
+ if err != nil {
16
+ t.Fatal(err)
17
+ }
18
+ var f struct {
19
+ Cases []struct {
20
+ ID string `json:"id"`
21
+ Category string `json:"category"`
22
+ Org string `json:"org"`
23
+ LeaderName string `json:"leaderName"`
24
+ LeaderEmpNo string `json:"leaderEmpNo"`
25
+ IdeaName string `json:"ideaName"`
26
+ WantBase string `json:"wantBase"`
27
+ WantBytes int `json:"wantBytes"`
28
+ WantTooLong bool `json:"wantTooLong"`
29
+ }
30
+ }
31
+ if err := json.Unmarshal(raw, &f); err != nil {
32
+ t.Fatal(err)
33
+ }
34
+ if len(f.Cases) == 0 {
35
+ t.Fatal("no cases")
36
+ }
37
+ for _, c := range f.Cases {
38
+ t.Run(c.ID, func(t *testing.T) {
39
+ got, err := storage.ExpectedFilenameBase(c.Category, c.Org, c.LeaderName, c.LeaderEmpNo, c.IdeaName)
40
+ if err != nil {
41
+ t.Fatal(err)
42
+ }
43
+ if got != c.WantBase {
44
+ t.Errorf("base = %q, want %q", got, c.WantBase)
45
+ }
46
+ if n := len(got + ".pptx"); n != c.WantBytes {
47
+ t.Errorf("bytes = %d, want %d", n, c.WantBytes)
48
+ }
49
+ err = storage.CheckFilenameLength(got)
50
+ if c.WantTooLong {
51
+ var pe *web.Error
52
+ if !errors.As(err, &pe) || pe.Code != web.CodeFilenameTooLong {
53
+ t.Errorf("err = %v, want FILENAME_TOO_LONG", err)
54
+ }
55
+ } else if err != nil {
56
+ t.Errorf("unexpected error %v", err)
57
+ }
58
+ })
59
+ }
60
+ }
@@ -0,0 +1,46 @@
1
+ package contract
2
+
3
+ import (
4
+ "encoding/json"
5
+ "os"
6
+ "slices"
7
+ "testing"
8
+
9
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/account"
10
+ )
11
+
12
+ func TestPasswordPolicy(t *testing.T) {
13
+ raw, err := os.ReadFile("../../../testdata/password.json")
14
+ if err != nil {
15
+ t.Fatal(err)
16
+ }
17
+ var f struct {
18
+ Cases []struct {
19
+ ID string `json:"id"`
20
+ Password string `json:"password"`
21
+ EmpNo string `json:"empNo"`
22
+ WantRules []string `json:"wantRules"`
23
+ }
24
+ }
25
+ if err := json.Unmarshal(raw, &f); err != nil {
26
+ t.Fatal(err)
27
+ }
28
+ if len(f.Cases) == 0 {
29
+ t.Fatal("no cases")
30
+ }
31
+ policy := account.DefaultPolicy()
32
+ for _, c := range f.Cases {
33
+ t.Run(c.ID, func(t *testing.T) {
34
+ got := []string{}
35
+ for _, fe := range policy.Check(c.Password, c.EmpNo) {
36
+ if fe.Field != "password" || fe.Code != "PASSWORD_POLICY" {
37
+ t.Errorf("entry %+v: want field password, code PASSWORD_POLICY", fe)
38
+ }
39
+ got = append(got, fe.Rule)
40
+ }
41
+ if !slices.Equal(got, c.WantRules) {
42
+ t.Errorf("rules = %v, want %v", got, c.WantRules)
43
+ }
44
+ })
45
+ }
46
+ }