@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.
- package/backend/api/openapi.yaml +83 -0
- package/backend/cmd/server/main.go +35 -2
- package/backend/db/migration/V005__idea.sql +49 -0
- package/backend/db/migration/V006__login_lock.sql +15 -0
- package/backend/db/migration/V007__login_attempt.sql +15 -0
- package/backend/db/migration/V008__pending_file_deletion.sql +27 -0
- package/backend/db/migration/V009__login_throttle_audit.sql +13 -0
- package/backend/go.mod +17 -3
- package/backend/go.sum +48 -7
- package/backend/internal/account/account_test.go +6 -6
- package/backend/internal/account/integration_test.go +45 -9
- package/backend/internal/account/keyboard.go +75 -0
- package/backend/internal/account/keyboard_test.go +43 -0
- package/backend/internal/account/logging_integration_test.go +1 -1
- package/backend/internal/account/password.go +125 -0
- package/backend/internal/account/validate.go +4 -21
- package/backend/internal/api/api.gen.go +224 -0
- package/backend/internal/auth/internal/store/login_attempt.go +53 -0
- package/backend/internal/auth/internal/store/login_lock.go +77 -0
- package/backend/internal/auth/internal/store/login_throttle_audit.go +42 -0
- package/backend/internal/auth/internal/store/session.go +15 -7
- package/backend/internal/auth/login.go +16 -4
- package/backend/internal/auth/login_integration_test.go +14 -1
- package/backend/internal/auth/login_ip_integration_test.go +153 -0
- package/backend/internal/auth/login_lock_integration_test.go +205 -0
- package/backend/internal/auth/login_test.go +15 -0
- package/backend/internal/auth/login_throttle_audit_integration_test.go +88 -0
- package/backend/internal/auth/purge.go +93 -0
- package/backend/internal/auth/purge_integration_test.go +220 -0
- package/backend/internal/auth/purge_test.go +51 -0
- package/backend/internal/auth/session.go +0 -6
- package/backend/internal/auth/session_integration_test.go +3 -3
- package/backend/internal/auth/throttle.go +122 -0
- package/backend/internal/deadline/gate.go +80 -0
- package/backend/internal/deadline/gate_integration_test.go +67 -0
- package/backend/internal/deadline/gate_test.go +61 -0
- package/backend/internal/idea/create.go +352 -0
- package/backend/internal/idea/create_integration_test.go +650 -0
- package/backend/internal/idea/handler.go +148 -0
- package/backend/internal/idea/handler_integration_test.go +107 -0
- package/backend/internal/idea/integration_test.go +149 -0
- package/backend/internal/idea/internal/store/list.go +146 -0
- package/backend/internal/idea/internal/store/list_test.go +33 -0
- package/backend/internal/idea/internal/store/store.go +166 -0
- package/backend/internal/idea/list.go +166 -0
- package/backend/internal/idea/list_integration_test.go +252 -0
- package/backend/internal/idea/list_test.go +96 -0
- package/backend/internal/idea/service.go +53 -0
- package/backend/internal/server/api.go +17 -0
- package/backend/internal/server/public.go +1 -0
- package/backend/internal/server/slides.go +21 -0
- package/backend/internal/shared/config/config.go +44 -1
- package/backend/internal/shared/config/config_test.go +21 -13
- package/backend/internal/shared/db/intx_integration_test.go +179 -0
- package/backend/internal/shared/dbtest/dbtest.go +21 -3
- package/backend/internal/shared/dbtest/faults.go +162 -0
- package/backend/internal/shared/job/job.go +54 -0
- package/backend/internal/shared/job/job_test.go +105 -0
- package/backend/internal/shared/web/multipart.go +175 -0
- package/backend/internal/shared/web/multipart_test.go +169 -0
- package/backend/internal/shared/web/problem.go +1 -0
- package/backend/internal/shared/web/request.go +42 -1
- package/backend/internal/shared/web/web_test.go +36 -0
- package/backend/internal/storage/expectedfilename.go +43 -0
- package/backend/internal/storage/expectedfilename_test.go +16 -0
- package/backend/internal/storage/internal/store/pending.go +41 -0
- package/backend/internal/storage/minio.go +139 -0
- package/backend/internal/storage/minio_integration_test.go +87 -0
- package/backend/internal/storage/minio_test.go +84 -0
- package/backend/internal/storage/pending.go +144 -0
- package/backend/internal/storage/pending_integration_test.go +324 -0
- package/backend/internal/storage/pending_test.go +23 -0
- package/backend/internal/storage/validator.go +100 -0
- package/backend/internal/storage/validator_test.go +95 -0
- package/backend/test/contract/expected_filename_test.go +60 -0
- package/backend/test/contract/password_test.go +46 -0
- package/backend/test/matrix/file_test.go +52 -0
- package/backend/test/matrix/load_test.go +62 -0
- package/backend/test/matrix/matrix_integration_test.go +291 -0
- package/backend/test/migration/pending_file_deletion_test.go +46 -0
- package/docs/API/api-endpoints.md +1 -1
- package/docs/API/error-codes.md +1 -1
- package/frontend/public/auth-hero-login.jpg +0 -0
- package/frontend/public/auth-hero-register.jpg +0 -0
- package/frontend/src/app/App.test.tsx +2 -42
- package/frontend/src/app/App.tsx +11 -2
- package/frontend/src/app/index.css +4 -4
- package/frontend/src/app/layouts.tsx +7 -135
- package/frontend/src/app/pages/JoinRequests.tsx +2 -2
- package/frontend/src/app/pages/NotFound.tsx +1 -1
- package/frontend/src/app/pages/admin.tsx +1 -1
- package/frontend/src/app/pages/auth.tsx +53 -25
- package/frontend/src/app/pages/ideas.tsx +7 -7
- package/frontend/src/app/pages/login.test.tsx +3 -54
- package/frontend/src/app/pages/register.test.tsx +1 -1
- package/frontend/src/app/router.tsx +2 -6
- package/frontend/src/features/auth/IdleWarning.test.tsx +82 -0
- package/frontend/src/features/auth/IdleWarning.tsx +66 -0
- package/frontend/src/features/auth/LoginForm.tsx +8 -1
- package/frontend/src/shared/api/activity.ts +20 -0
- package/frontend/src/shared/api/http.ts +2 -0
- package/frontend/src/shared/api/index.ts +1 -0
- package/frontend/src/shared/i18n/locales/en.ts +1 -0
- package/frontend/src/shared/i18n/locales/zh-TW.ts +1 -0
- package/package.json +1 -1
- package/testdata/expected-filename.json +93 -0
- package/testdata/password.json +425 -0
- package/testdata/permission-matrix.json +157 -0
- package/testdata/schema/expected-filename.schema.json +32 -0
- package/testdata/schema/password.schema.json +59 -0
- package/testdata/schema/permission-matrix.schema.json +73 -0
|
@@ -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
|
+
}
|
|
@@ -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
|
+
}
|