@johnnyboy11234/esg-idea-contest 0.3.1 → 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.
- package/backend/api/openapi.yaml +36 -0
- package/backend/cmd/server/main.go +1 -0
- package/backend/internal/account/internal/passwords/NOTICE +36 -0
- package/backend/internal/account/internal/passwords/common-passwords.txt +10001 -0
- package/backend/internal/account/internal/passwords/passwords.go +37 -0
- package/backend/internal/account/internal/passwords/passwords_test.go +35 -0
- package/backend/internal/account/password.go +8 -2
- package/backend/internal/account/sequence.go +54 -0
- package/backend/internal/account/sequence_test.go +38 -0
- package/backend/internal/api/api.gen.go +38 -0
- package/backend/internal/idea/create.go +5 -1
- package/backend/internal/idea/create_integration_test.go +33 -3
- package/backend/internal/idea/download.go +193 -0
- package/backend/internal/idea/download_integration_test.go +311 -0
- package/backend/internal/idea/download_test.go +25 -0
- package/backend/internal/idea/handler.go +3 -0
- package/backend/internal/idea/service.go +2 -1
- package/backend/internal/server/api.go +5 -0
- package/backend/internal/server/binding.go +4 -0
- package/backend/internal/server/server.go +20 -1
- package/backend/internal/server/slides_test.go +82 -0
- package/backend/internal/shared/web/transport.go +4 -3
- package/backend/test/arch/arch_test.go +5 -1
- package/backend/test/matrix/matrix_integration_test.go +6 -0
- package/docs/Architecture/Architecture.md +1 -1
- package/docs/Architecture/Backend.md +2 -1
- package/frontend/Dockerfile +2 -0
- package/frontend/package-lock.json +1 -0
- package/frontend/package.json +1 -0
- package/frontend/src/app/pages/ideaCreate.test.tsx +308 -0
- package/frontend/src/app/pages/ideas.tsx +23 -9
- package/frontend/src/app/pages/login.test.tsx +38 -1
- package/frontend/src/app/pages/register.test.tsx +32 -0
- package/frontend/src/features/auth/LoginForm.tsx +21 -3
- package/frontend/src/features/auth/PasswordChecklist.test.tsx +73 -0
- package/frontend/src/features/auth/PasswordChecklist.tsx +96 -0
- package/frontend/src/features/auth/RegisterForm.tsx +11 -2
- package/frontend/src/features/auth/common-passwords.ts +31 -0
- package/frontend/src/features/auth/password-policy.test.ts +64 -0
- package/frontend/src/features/auth/password-policy.ts +168 -0
- package/frontend/src/features/auth/useCountdown.test.tsx +33 -0
- package/frontend/src/features/auth/useCountdown.ts +28 -0
- package/frontend/src/features/idea/CreateIdeaForm.tsx +279 -0
- package/frontend/src/features/idea/create.ts +123 -0
- package/frontend/src/shared/api/types.generated.ts +319 -1
- package/frontend/src/shared/i18n/locales/en.ts +45 -4
- package/frontend/src/shared/i18n/locales/zh-TW.ts +45 -4
- package/frontend/src/shared/slides/index.ts +74 -0
- package/frontend/src/shared/slides/slides.test.ts +51 -0
- package/frontend/src/shared/ui/copy-button.tsx +52 -0
- package/frontend/src/shared/ui/native-select.tsx +27 -0
- package/frontend/src/shared/ui/textarea.tsx +19 -0
- package/frontend/src/test-setup.ts +37 -2
- package/frontend/vite.config.ts +3 -1
- package/package.json +1 -1
- package/testdata/password.json +164 -7
- package/testdata/permission-matrix.json +23 -0
- package/testdata/schema/permission-matrix.schema.json +1 -1
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
//go:build integration
|
|
2
|
+
|
|
3
|
+
package idea_test
|
|
4
|
+
|
|
5
|
+
import (
|
|
6
|
+
"bytes"
|
|
7
|
+
"context"
|
|
8
|
+
"encoding/json"
|
|
9
|
+
"errors"
|
|
10
|
+
"io"
|
|
11
|
+
"log/slog"
|
|
12
|
+
"net/http"
|
|
13
|
+
"net/http/httptest"
|
|
14
|
+
"strconv"
|
|
15
|
+
"testing"
|
|
16
|
+
|
|
17
|
+
"github.com/go-chi/chi/v5"
|
|
18
|
+
"github.com/google/uuid"
|
|
19
|
+
|
|
20
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/api"
|
|
21
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/idea"
|
|
22
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/shared/dbtest"
|
|
23
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/shared/security"
|
|
24
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/shared/web"
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
// created posts a valid create as p and returns the new Idea's ID and the key
|
|
28
|
+
// of its stored object.
|
|
29
|
+
func (e *createEnv) created(t *testing.T, p security.Principal, name string) (uuid.UUID, string) {
|
|
30
|
+
t.Helper()
|
|
31
|
+
return e.createdWith(t, p, validForm(name))
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
func (e *createEnv) createdWith(t *testing.T, p security.Principal, f form) (uuid.UUID, string) {
|
|
35
|
+
t.Helper()
|
|
36
|
+
rec := e.post(t, p, f)
|
|
37
|
+
if rec.Code != http.StatusCreated {
|
|
38
|
+
t.Fatalf("create: %d %s", rec.Code, rec.Body)
|
|
39
|
+
}
|
|
40
|
+
var b struct{ ID uuid.UUID }
|
|
41
|
+
if err := json.Unmarshal(rec.Body.Bytes(), &b); err != nil {
|
|
42
|
+
t.Fatal(err)
|
|
43
|
+
}
|
|
44
|
+
return b.ID, e.objects.last
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
func (e *createEnv) downloadRouter() http.Handler {
|
|
48
|
+
r := chi.NewRouter()
|
|
49
|
+
r.Get("/ideas/{id}/slides", func(w http.ResponseWriter, req *http.Request) {
|
|
50
|
+
id, err := uuid.Parse(chi.URLParam(req, "id"))
|
|
51
|
+
if err != nil {
|
|
52
|
+
panic(err)
|
|
53
|
+
}
|
|
54
|
+
e.h.DownloadSlides(w, req, api.IdeaId(id))
|
|
55
|
+
})
|
|
56
|
+
return r
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
func (e *createEnv) download(t *testing.T, p security.Principal, id uuid.UUID) *httptest.ResponseRecorder {
|
|
60
|
+
t.Helper()
|
|
61
|
+
req := httptest.NewRequest(http.MethodGet, "/ideas/"+id.String()+"/slides", nil)
|
|
62
|
+
req = req.WithContext(security.WithPrincipal(context.Background(), p))
|
|
63
|
+
rec := httptest.NewRecorder()
|
|
64
|
+
e.downloadRouter().ServeHTTP(rec, req)
|
|
65
|
+
return rec
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
func (e *createEnv) gets() int {
|
|
69
|
+
e.objects.mu.Lock()
|
|
70
|
+
defer e.objects.mu.Unlock()
|
|
71
|
+
return e.objects.gets
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
func TestDownloadSlides(t *testing.T) {
|
|
75
|
+
e := newCreateEnv(t)
|
|
76
|
+
leader := addUser(t, e.db, "100001", "USER")
|
|
77
|
+
other := addUser(t, e.db, "100002", "USER")
|
|
78
|
+
admin := addUser(t, e.db, "900001", "ADMIN")
|
|
79
|
+
id, key := e.created(t, leader, "Idea A")
|
|
80
|
+
successRows := func() int {
|
|
81
|
+
return e.count(t, `SELECT COUNT(*) FROM audit_log WHERE action = 'SLIDES_DOWNLOAD' AND outcome = 'SUCCESS'`)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// The audit row is committed (visible to another connection) before the
|
|
85
|
+
// object is even opened, so before the first byte.
|
|
86
|
+
rowsAtOpen := -1
|
|
87
|
+
e.objects.onGet = func() { rowsAtOpen = successRows() }
|
|
88
|
+
|
|
89
|
+
rec := e.download(t, leader, id)
|
|
90
|
+
if rec.Code != http.StatusOK {
|
|
91
|
+
t.Fatalf("leader: %d %s", rec.Code, rec.Body)
|
|
92
|
+
}
|
|
93
|
+
if rowsAtOpen != 1 {
|
|
94
|
+
t.Errorf("SLIDES_DOWNLOAD rows when the object was opened = %d, want 1", rowsAtOpen)
|
|
95
|
+
}
|
|
96
|
+
if !bytes.Equal(rec.Body.Bytes(), e.objects.puts[key]) {
|
|
97
|
+
t.Error("body differs from the stored object")
|
|
98
|
+
}
|
|
99
|
+
want := map[string]string{
|
|
100
|
+
"Content-Type": "application/pdf",
|
|
101
|
+
"Content-Length": strconv.Itoa(len(e.objects.puts[key])),
|
|
102
|
+
"Content-Disposition": idea.ContentDisposition("I_BSID_User 100001_100001_Idea A.pdf"),
|
|
103
|
+
"Cache-Control": "no-store",
|
|
104
|
+
"X-Content-Type-Options": "nosniff",
|
|
105
|
+
}
|
|
106
|
+
for k, v := range want {
|
|
107
|
+
if got := rec.Header().Get(k); got != v {
|
|
108
|
+
t.Errorf("%s = %q, want %q", k, got, v)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
if rec.Header().Get("Accept-Ranges") != "" {
|
|
112
|
+
t.Error("Range must not be advertised")
|
|
113
|
+
}
|
|
114
|
+
if n := e.count(t, `SELECT COUNT(*) FROM audit_log WHERE action = 'SLIDES_DOWNLOAD' AND outcome = 'SUCCESS'
|
|
115
|
+
AND actor_role = 'USER' AND actor_emp_no = '100001' AND target_type = 'IDEA' AND target_id = ?
|
|
116
|
+
AND method = 'GET' AND route = '/ideas/{id}/slides' AND status = 200`, id.String()); n != 1 {
|
|
117
|
+
t.Errorf("leader SUCCESS row with full fields = %d, want 1", n)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Admin: 200 and a second SUCCESS row.
|
|
121
|
+
if rec := e.download(t, admin, id); rec.Code != http.StatusOK {
|
|
122
|
+
t.Fatalf("admin: %d %s", rec.Code, rec.Body)
|
|
123
|
+
}
|
|
124
|
+
if n := e.count(t, `SELECT COUNT(*) FROM audit_log WHERE action = 'SLIDES_DOWNLOAD' AND outcome = 'SUCCESS' AND actor_role = 'ADMIN'`); n != 1 {
|
|
125
|
+
t.Errorf("admin SUCCESS rows = %d, want 1", n)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Hidden and missing: identical 404 (bar the request ID), both DENIED, and
|
|
129
|
+
// storage is never touched.
|
|
130
|
+
before := e.gets()
|
|
131
|
+
hidden, missing := e.download(t, other, id), e.download(t, other, uuid.New())
|
|
132
|
+
if hidden.Code != 404 || missing.Code != 404 {
|
|
133
|
+
t.Fatalf("codes %d %d", hidden.Code, missing.Code)
|
|
134
|
+
}
|
|
135
|
+
strip := func(rr *httptest.ResponseRecorder) map[string]any {
|
|
136
|
+
var m map[string]any
|
|
137
|
+
_ = json.Unmarshal(rr.Body.Bytes(), &m)
|
|
138
|
+
delete(m, "requestId")
|
|
139
|
+
return m
|
|
140
|
+
}
|
|
141
|
+
hb, mb := strip(hidden), strip(missing)
|
|
142
|
+
if len(hb) != len(mb) || hb["code"] != "NOT_FOUND" || hb["code"] != mb["code"] || hb["detail"] != mb["detail"] {
|
|
143
|
+
t.Errorf("bodies differ: %v vs %v", hb, mb)
|
|
144
|
+
}
|
|
145
|
+
for k := range hidden.Header() {
|
|
146
|
+
if hidden.Header().Get(k) != missing.Header().Get(k) {
|
|
147
|
+
t.Errorf("header %s differs", k)
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
if e.gets() != before {
|
|
151
|
+
t.Error("a 404 must not open the object")
|
|
152
|
+
}
|
|
153
|
+
if n := e.count(t, `SELECT COUNT(*) FROM audit_log WHERE action = 'SLIDES_DOWNLOAD' AND outcome = 'DENIED'
|
|
154
|
+
AND status = 404 AND error_code = 'NOT_FOUND'`); n != 2 {
|
|
155
|
+
t.Errorf("DENIED rows = %d, want 2", n)
|
|
156
|
+
}
|
|
157
|
+
if successRows() != 2 {
|
|
158
|
+
t.Errorf("SUCCESS rows = %d, want 2", successRows())
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Not state-limited, and the name is the *current* expected filename.
|
|
162
|
+
e.setDeadline(t, "NOW(6) - INTERVAL 1 DAY")
|
|
163
|
+
if _, err := e.db.Exec(`UPDATE idea SET name = 'Renamed' WHERE id = ?`, id.String()); err != nil {
|
|
164
|
+
t.Fatal(err)
|
|
165
|
+
}
|
|
166
|
+
rec = e.download(t, leader, id)
|
|
167
|
+
if rec.Code != http.StatusOK {
|
|
168
|
+
t.Fatalf("after deadline: %d %s", rec.Code, rec.Body)
|
|
169
|
+
}
|
|
170
|
+
if got, want := rec.Header().Get("Content-Disposition"), idea.ContentDisposition("I_BSID_User 100001_100001_Renamed.pdf"); got != want {
|
|
171
|
+
t.Errorf("Content-Disposition = %q, want %q", got, want)
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// A non-ASCII Idea name is served exactly through filename*.
|
|
176
|
+
func TestDownloadSlidesUnicodeFilename(t *testing.T) {
|
|
177
|
+
e := newCreateEnv(t)
|
|
178
|
+
leader := addUser(t, e.db, "100001", "USER")
|
|
179
|
+
id, _ := e.created(t, leader, "減碳 Idea")
|
|
180
|
+
rec := e.download(t, leader, id)
|
|
181
|
+
if rec.Code != http.StatusOK {
|
|
182
|
+
t.Fatalf("%d %s", rec.Code, rec.Body)
|
|
183
|
+
}
|
|
184
|
+
want := `attachment; filename="I_BSID_User 100001_100001___ Idea.pdf"; ` +
|
|
185
|
+
`filename*=UTF-8''I_BSID_User%20100001_100001_%E6%B8%9B%E7%A2%B3%20Idea.pdf`
|
|
186
|
+
if got := rec.Header().Get("Content-Disposition"); got != want {
|
|
187
|
+
t.Errorf("Content-Disposition = %q, want %q", got, want)
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Failures before the first byte answer a problem; the audit row stays
|
|
192
|
+
// (Sequence.md §6: the intent to download was real).
|
|
193
|
+
func TestDownloadSlidesFailsBeforeFirstByte(t *testing.T) {
|
|
194
|
+
for _, tc := range []struct {
|
|
195
|
+
name string
|
|
196
|
+
breakIt func(e *createEnv, id uuid.UUID, key string)
|
|
197
|
+
status int
|
|
198
|
+
code string
|
|
199
|
+
}{
|
|
200
|
+
{"MinIO down", func(e *createEnv, _ uuid.UUID, _ string) {
|
|
201
|
+
e.objects.getErr = web.NewError(web.CodeStorageUnavailable, "object storage is unavailable")
|
|
202
|
+
}, 503, "STORAGE_UNAVAILABLE"},
|
|
203
|
+
{"object missing", func(e *createEnv, _ uuid.UUID, key string) {
|
|
204
|
+
delete(e.objects.puts, key)
|
|
205
|
+
}, 500, "INTERNAL_ERROR"},
|
|
206
|
+
{"object length differs from the row", func(e *createEnv, id uuid.UUID, _ string) {
|
|
207
|
+
if _, err := e.db.Exec(`UPDATE idea SET slides_size = slides_size + 1 WHERE id = ?`, id.String()); err != nil {
|
|
208
|
+
panic(err)
|
|
209
|
+
}
|
|
210
|
+
}, 500, "INTERNAL_ERROR"},
|
|
211
|
+
} {
|
|
212
|
+
t.Run(tc.name, func(t *testing.T) {
|
|
213
|
+
e := newCreateEnv(t)
|
|
214
|
+
leader := addUser(t, e.db, "100001", "USER")
|
|
215
|
+
id, key := e.created(t, leader, "Idea A")
|
|
216
|
+
tc.breakIt(e, id, key)
|
|
217
|
+
rec := e.download(t, leader, id)
|
|
218
|
+
if rec.Code != tc.status || decodeProblem(t, rec).Code != tc.code {
|
|
219
|
+
t.Fatalf("%d %s, want %d %s", rec.Code, rec.Body, tc.status, tc.code)
|
|
220
|
+
}
|
|
221
|
+
if ct := rec.Header().Get("Content-Type"); ct != web.ProblemContentType {
|
|
222
|
+
t.Errorf("Content-Type = %q", ct)
|
|
223
|
+
}
|
|
224
|
+
if n := e.count(t, `SELECT COUNT(*) FROM audit_log WHERE action = 'SLIDES_DOWNLOAD' AND outcome = 'SUCCESS'`); n != 1 {
|
|
225
|
+
t.Errorf("SUCCESS rows = %d, want 1", n)
|
|
226
|
+
}
|
|
227
|
+
})
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// A storage failure mid-stream aborts the connection, so the client never
|
|
232
|
+
// takes a short body for the whole file. Past net/http's write buffer the 200
|
|
233
|
+
// and its Content-Length are already out and the body ends early; before it,
|
|
234
|
+
// the client gets no response at all.
|
|
235
|
+
func TestDownloadSlidesAbortsMidStream(t *testing.T) {
|
|
236
|
+
const size = 64 << 10
|
|
237
|
+
for _, tc := range []struct {
|
|
238
|
+
name string
|
|
239
|
+
failAfter int64
|
|
240
|
+
headers bool // the 200 reached the client
|
|
241
|
+
}{
|
|
242
|
+
{"after the headers were sent", 32 << 10, true},
|
|
243
|
+
{"within the write buffer", 10, false},
|
|
244
|
+
} {
|
|
245
|
+
t.Run(tc.name, func(t *testing.T) {
|
|
246
|
+
e := newCreateEnv(t)
|
|
247
|
+
leader := addUser(t, e.db, "100001", "USER")
|
|
248
|
+
f := validForm("Idea A")
|
|
249
|
+
f.file = pdf(size)
|
|
250
|
+
id, key := e.createdWith(t, leader, f)
|
|
251
|
+
total := int64(len(e.objects.puts[key]))
|
|
252
|
+
e.objects.failAfter = tc.failAfter
|
|
253
|
+
e.objects.streamErr = errors.New("minio: connection reset")
|
|
254
|
+
|
|
255
|
+
inner := e.downloadRouter()
|
|
256
|
+
srv := httptest.NewServer(web.Recover(slog.New(slog.DiscardHandler))(
|
|
257
|
+
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
258
|
+
inner.ServeHTTP(w, r.WithContext(security.WithPrincipal(r.Context(), leader)))
|
|
259
|
+
})))
|
|
260
|
+
t.Cleanup(srv.Close)
|
|
261
|
+
|
|
262
|
+
resp, err := http.Get(srv.URL + "/ideas/" + id.String() + "/slides")
|
|
263
|
+
if !tc.headers {
|
|
264
|
+
if err == nil {
|
|
265
|
+
_ = resp.Body.Close()
|
|
266
|
+
t.Fatalf("got a response (%d); want the connection aborted", resp.StatusCode)
|
|
267
|
+
}
|
|
268
|
+
return
|
|
269
|
+
}
|
|
270
|
+
if err != nil {
|
|
271
|
+
t.Fatal(err)
|
|
272
|
+
}
|
|
273
|
+
defer func() { _ = resp.Body.Close() }()
|
|
274
|
+
if resp.StatusCode != 200 || resp.ContentLength != total {
|
|
275
|
+
t.Fatalf("status %d, length %d, want 200 %d", resp.StatusCode, resp.ContentLength, total)
|
|
276
|
+
}
|
|
277
|
+
b, err := io.ReadAll(resp.Body)
|
|
278
|
+
if !errors.Is(err, io.ErrUnexpectedEOF) {
|
|
279
|
+
t.Errorf("read error = %v, want unexpected EOF", err)
|
|
280
|
+
}
|
|
281
|
+
if int64(len(b)) >= total {
|
|
282
|
+
t.Errorf("read %d of %d bytes; want a short body", len(b), total)
|
|
283
|
+
}
|
|
284
|
+
})
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// An unknown commit outcome serves nothing: the audit row may not exist.
|
|
289
|
+
func TestDownloadSlidesUnknownOutcome(t *testing.T) {
|
|
290
|
+
dsn := dbtest.Start(t)
|
|
291
|
+
d := dbtest.Open(t, dsn)
|
|
292
|
+
faulty, faults := dbtest.OpenFaulty(t, dsn)
|
|
293
|
+
e := newCreateEnvOn(t, d, faulty)
|
|
294
|
+
leader := addUser(t, d, "100001", "USER")
|
|
295
|
+
id, _ := e.created(t, leader, "Idea A")
|
|
296
|
+
|
|
297
|
+
for _, fault := range []dbtest.CommitFault{dbtest.LostAckCommitted, dbtest.LostAckNotCommitted} {
|
|
298
|
+
before := e.gets()
|
|
299
|
+
faults.FailNextCommit(fault)
|
|
300
|
+
rec := e.download(t, leader, id)
|
|
301
|
+
if rec.Code != 503 || decodeProblem(t, rec).Code != "SERVICE_UNAVAILABLE" {
|
|
302
|
+
t.Fatalf("fault %v: %d %s", fault, rec.Code, rec.Body)
|
|
303
|
+
}
|
|
304
|
+
if e.gets() != before {
|
|
305
|
+
t.Errorf("fault %v: the object must not be opened", fault)
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
if faults.Fired() != 2 {
|
|
309
|
+
t.Errorf("faults fired = %d, want 2", faults.Fired())
|
|
310
|
+
}
|
|
311
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
package idea_test
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"mime"
|
|
5
|
+
"testing"
|
|
6
|
+
|
|
7
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/idea"
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
func TestContentDisposition(t *testing.T) {
|
|
11
|
+
for _, tc := range []struct{ name, want string }{
|
|
12
|
+
{"I_BSID_Amy_100001_Plan.pdf", `attachment; filename="I_BSID_Amy_100001_Plan.pdf"; filename*=UTF-8''I_BSID_Amy_100001_Plan.pdf`},
|
|
13
|
+
{"O_AAID_王小明_100002_減碳.pptx", `attachment; filename="O_AAID_____100002___.pptx"; filename*=UTF-8''O_AAID_%E7%8E%8B%E5%B0%8F%E6%98%8E_100002_%E6%B8%9B%E7%A2%B3.pptx`},
|
|
14
|
+
{`a "b"\c;d%e.pdf`, `attachment; filename="a _b__c;d%e.pdf"; filename*=UTF-8''a%20%22b%22%5Cc%3Bd%25e.pdf`},
|
|
15
|
+
} {
|
|
16
|
+
got := idea.ContentDisposition(tc.name)
|
|
17
|
+
if got != tc.want {
|
|
18
|
+
t.Errorf("%q:\n got %s\nwant %s", tc.name, got, tc.want)
|
|
19
|
+
}
|
|
20
|
+
// Go's own RFC 2231/8187 parser recovers the exact name from filename*.
|
|
21
|
+
if _, params, err := mime.ParseMediaType(got); err != nil || params["filename"] != tc.name {
|
|
22
|
+
t.Errorf("%q: parsed %q, %v", tc.name, params["filename"], err)
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -4,6 +4,7 @@ import (
|
|
|
4
4
|
"context"
|
|
5
5
|
"database/sql"
|
|
6
6
|
"errors"
|
|
7
|
+
"log/slog"
|
|
7
8
|
"net/http"
|
|
8
9
|
|
|
9
10
|
"github.com/google/uuid"
|
|
@@ -51,6 +52,8 @@ type Handler struct {
|
|
|
51
52
|
// storage, D29).
|
|
52
53
|
ExpectedFilename func(category, org, leaderName, leaderEmpNo, ideaName string) (string, error)
|
|
53
54
|
Fail func(w http.ResponseWriter, r *http.Request, err error)
|
|
55
|
+
// Log receives download failures the response cannot carry (nil = discard).
|
|
56
|
+
Log *slog.Logger
|
|
54
57
|
}
|
|
55
58
|
|
|
56
59
|
type person struct {
|
|
@@ -23,7 +23,8 @@ type Idea = store.Idea
|
|
|
23
23
|
type Service struct {
|
|
24
24
|
DB *sql.DB
|
|
25
25
|
Audit *audit.Service
|
|
26
|
-
// Gate, Slides
|
|
26
|
+
// Gate, Slides and Aborter are needed by Create only; Objects by Create
|
|
27
|
+
// and Download.
|
|
27
28
|
Gate deadline.SubmissionGate
|
|
28
29
|
Slides SlideRules
|
|
29
30
|
Objects ObjectStore
|
|
@@ -59,3 +59,8 @@ func (a API) CreateIdea(w http.ResponseWriter, r *http.Request, _ api.CreateIdea
|
|
|
59
59
|
func (a API) ListIdeas(w http.ResponseWriter, r *http.Request, params api.ListIdeasParams) {
|
|
60
60
|
a.Ideas.ListIdeas(w, r, params)
|
|
61
61
|
}
|
|
62
|
+
|
|
63
|
+
// DownloadSlides serves GET /ideas/{id}/slides.
|
|
64
|
+
func (a API) DownloadSlides(w http.ResponseWriter, r *http.Request, id api.IdeaId) {
|
|
65
|
+
a.Ideas.DownloadSlides(w, r, id)
|
|
66
|
+
}
|
|
@@ -18,6 +18,10 @@ func Mount(si api.ServerInterface) func(chi.Router) {
|
|
|
18
18
|
BaseRouter: r,
|
|
19
19
|
ErrorHandlerFunc: BindingError,
|
|
20
20
|
})
|
|
21
|
+
// HEAD on the download must never run the GET handler, which writes an
|
|
22
|
+
// audit row (D47). chi already answers 405; the explicit route keeps
|
|
23
|
+
// the rule independent of that router default (D59).
|
|
24
|
+
r.Head(SlidesPath, web.MethodNotAllowed)
|
|
21
25
|
}
|
|
22
26
|
}
|
|
23
27
|
|
|
@@ -6,6 +6,7 @@ import (
|
|
|
6
6
|
"database/sql"
|
|
7
7
|
"log/slog"
|
|
8
8
|
"net/http"
|
|
9
|
+
"strings"
|
|
9
10
|
"sync/atomic"
|
|
10
11
|
"time"
|
|
11
12
|
|
|
@@ -32,13 +33,31 @@ func NewPublic(log *slog.Logger, mount func(chi.Router), extra ...func(http.Hand
|
|
|
32
33
|
}
|
|
33
34
|
// The chain wraps the router instead of using r.Use: chi skips r.Use
|
|
34
35
|
// middleware (also for NotFound) while no route is registered.
|
|
35
|
-
h := web.AcceptCheck(r)
|
|
36
|
+
h := web.AcceptCheck(r, IsFileDownload)
|
|
36
37
|
for i := len(extra) - 1; i >= 0; i-- {
|
|
37
38
|
h = extra[i](h)
|
|
38
39
|
}
|
|
39
40
|
return web.RequestID(web.AccessLog(log)(web.Recover(log)(h)))
|
|
40
41
|
}
|
|
41
42
|
|
|
43
|
+
// SlidesPath is the slide download route under /api/v1.
|
|
44
|
+
const SlidesPath = "/api/v1/ideas/{id}/slides"
|
|
45
|
+
|
|
46
|
+
// IsFileDownload reports whether r targets the slide download, which serves a
|
|
47
|
+
// file and therefore skips the JSON Accept check. HEAD is included so that it
|
|
48
|
+
// answers 405, never 406. Errors on this route are still problem+json.
|
|
49
|
+
func IsFileDownload(r *http.Request) bool {
|
|
50
|
+
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
|
51
|
+
return false
|
|
52
|
+
}
|
|
53
|
+
rest, ok := strings.CutPrefix(r.URL.Path, "/api/v1/ideas/")
|
|
54
|
+
if !ok {
|
|
55
|
+
return false
|
|
56
|
+
}
|
|
57
|
+
id, tail, ok := strings.Cut(rest, "/")
|
|
58
|
+
return ok && id != "" && tail == "slides"
|
|
59
|
+
}
|
|
60
|
+
|
|
42
61
|
// Check reports whether one dependency is usable; a non-nil error makes
|
|
43
62
|
// readiness answer 503. Only dependencies a request cannot do without belong
|
|
44
63
|
// here: the database yes, MinIO no (PRD §8.3, Architecture §12).
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
package server_test
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"log/slog"
|
|
5
|
+
"net/http"
|
|
6
|
+
"net/http/httptest"
|
|
7
|
+
"testing"
|
|
8
|
+
|
|
9
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/api"
|
|
10
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/server"
|
|
11
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/shared/web"
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
// downloadStub counts DownloadSlides calls; every other operation is 501.
|
|
15
|
+
type downloadStub struct {
|
|
16
|
+
api.Unimplemented
|
|
17
|
+
calls *int
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
func (s downloadStub) DownloadSlides(w http.ResponseWriter, _ *http.Request, _ api.IdeaId) {
|
|
21
|
+
*s.calls++
|
|
22
|
+
w.Header().Set("Content-Type", "application/pdf")
|
|
23
|
+
_, _ = w.Write([]byte("%PDF-"))
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
func TestSlidesDownloadRoute(t *testing.T) {
|
|
27
|
+
const path = "/api/v1/ideas/7f0c1a52-2a51-4f0e-9a43-0d6b5f0b2a11/slides"
|
|
28
|
+
for _, tc := range []struct {
|
|
29
|
+
name, method, path, accept string
|
|
30
|
+
status int
|
|
31
|
+
code string
|
|
32
|
+
calls int
|
|
33
|
+
}{
|
|
34
|
+
// D47/D59: HEAD answers 405 and never runs the GET handler (which audits).
|
|
35
|
+
{"HEAD is 405", http.MethodHead, path, "", 405, "METHOD_NOT_ALLOWED", 0},
|
|
36
|
+
{"HEAD with a file Accept is still 405, not 406", http.MethodHead, path, "application/pdf", 405, "METHOD_NOT_ALLOWED", 0},
|
|
37
|
+
{"GET with a file Accept is served", http.MethodGet, path, "application/pdf", 200, "", 1},
|
|
38
|
+
{"GET with a browser Accept is served", http.MethodGet, path, "text/html,application/xhtml+xml", 200, "", 1},
|
|
39
|
+
{"GET with no Accept is served", http.MethodGet, path, "", 200, "", 1},
|
|
40
|
+
{"malformed ID is the uniform 404", http.MethodGet, "/api/v1/ideas/nope/slides", "", 404, "NOT_FOUND", 0},
|
|
41
|
+
{"other routes keep the 406", http.MethodGet, "/api/v1/ideas/7f0c1a52-2a51-4f0e-9a43-0d6b5f0b2a11", "application/pdf", 406, "NOT_ACCEPTABLE", 0},
|
|
42
|
+
{"wrong method on the download is 405", http.MethodPost, path, "", 405, "METHOD_NOT_ALLOWED", 0},
|
|
43
|
+
} {
|
|
44
|
+
t.Run(tc.name, func(t *testing.T) {
|
|
45
|
+
calls := 0
|
|
46
|
+
h := server.NewPublic(slog.New(slog.DiscardHandler), server.Mount(downloadStub{calls: &calls}))
|
|
47
|
+
req := httptest.NewRequest(tc.method, tc.path, nil)
|
|
48
|
+
if tc.accept != "" {
|
|
49
|
+
req.Header.Set("Accept", tc.accept)
|
|
50
|
+
}
|
|
51
|
+
rec := httptest.NewRecorder()
|
|
52
|
+
h.ServeHTTP(rec, req)
|
|
53
|
+
if rec.Code != tc.status || calls != tc.calls {
|
|
54
|
+
t.Fatalf("status %d calls %d, want %d %d (%s)", rec.Code, calls, tc.status, tc.calls, rec.Body)
|
|
55
|
+
}
|
|
56
|
+
if tc.code != "" {
|
|
57
|
+
if ct := rec.Header().Get("Content-Type"); ct != web.ProblemContentType {
|
|
58
|
+
t.Errorf("Content-Type %q", ct)
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
})
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
func TestIsFileDownload(t *testing.T) {
|
|
66
|
+
for _, tc := range []struct {
|
|
67
|
+
method, path string
|
|
68
|
+
want bool
|
|
69
|
+
}{
|
|
70
|
+
{http.MethodGet, "/api/v1/ideas/x/slides", true},
|
|
71
|
+
{http.MethodHead, "/api/v1/ideas/x/slides", true},
|
|
72
|
+
{http.MethodPut, "/api/v1/ideas/x/slides", false},
|
|
73
|
+
{http.MethodGet, "/api/v1/ideas//slides", false},
|
|
74
|
+
{http.MethodGet, "/api/v1/ideas/x/slides/", false},
|
|
75
|
+
{http.MethodGet, "/api/v1/ideas/x", false},
|
|
76
|
+
{http.MethodGet, "/api/v1/ideas/x/members/slides", false},
|
|
77
|
+
} {
|
|
78
|
+
if got := server.IsFileDownload(httptest.NewRequest(tc.method, tc.path, nil)); got != tc.want {
|
|
79
|
+
t.Errorf("%s %s = %v", tc.method, tc.path, got)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -20,10 +20,11 @@ func MethodNotAllowed(w http.ResponseWriter, r *http.Request) {
|
|
|
20
20
|
|
|
21
21
|
// AcceptCheck answers 406 when the Accept header cannot be satisfied by JSON.
|
|
22
22
|
// A missing header, */*, application/* and application/json (also +problem)
|
|
23
|
-
// are accepted.
|
|
24
|
-
|
|
23
|
+
// are accepted. Requests for which exempt reports true (routes that serve a
|
|
24
|
+
// file, not JSON) skip the check; exempt may be nil.
|
|
25
|
+
func AcceptCheck(next http.Handler, exempt func(*http.Request) bool) http.Handler {
|
|
25
26
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
26
|
-
if !acceptsJSON(r.Header.Values("Accept")) {
|
|
27
|
+
if (exempt == nil || !exempt(r)) && !acceptsJSON(r.Header.Values("Accept")) {
|
|
27
28
|
WriteProblem(w, r, NewError(CodeNotAcceptable, "Only application/json is supported."))
|
|
28
29
|
return
|
|
29
30
|
}
|
|
@@ -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"},
|
|
@@ -7,6 +7,7 @@ import (
|
|
|
7
7
|
"context"
|
|
8
8
|
"database/sql"
|
|
9
9
|
"encoding/json"
|
|
10
|
+
"errors"
|
|
10
11
|
"fmt"
|
|
11
12
|
"io"
|
|
12
13
|
"log/slog"
|
|
@@ -95,6 +96,11 @@ func (discardObjects) Put(_ context.Context, _ string, r io.Reader, _ int64, _ s
|
|
|
95
96
|
return err
|
|
96
97
|
}
|
|
97
98
|
|
|
99
|
+
// Get is never reached by the matrix rows (they download only missing Ideas).
|
|
100
|
+
func (discardObjects) Get(context.Context, string) (io.ReadCloser, int64, error) {
|
|
101
|
+
return nil, 0, errors.New("matrix: no objects")
|
|
102
|
+
}
|
|
103
|
+
|
|
98
104
|
func (discardObjects) AbortUpload(context.Context, string, shareddb.Outcome) {}
|
|
99
105
|
|
|
100
106
|
// stateDeadline is the deadline expression that puts the contest in a state.
|
|
@@ -149,7 +149,7 @@ Next deliverables: ADR drafts for the decisions below; Flyway migration plan for
|
|
|
149
149
|
| D26 | No `job_lease` table in v1 | Idempotent operations and `SKIP LOCKED` are enough | Decided |
|
|
150
150
|
| D27 | MariaDB 10.8 LTS with native `UUID` and `INET6` | Version confirmed by the owner | **Reopened** (D45) |
|
|
151
151
|
| D28 | No dedicated Spring Boot 4 compatibility spike | Owner decision; risk accepted | **Superseded: no Spring in the Go backend (OI-31 closed)** (was: Decided (OI-31)) |
|
|
152
|
-
| D29 | Split `slides` into `storage` (no dependency on `idea`) and `slides` (HTTP orchestration on top of `idea` + `storage`) | Removes the `idea` ↔ `slides` cycle | Decided |
|
|
152
|
+
| D29 | Split `slides` into `storage` (no dependency on `idea`) and `slides` (HTTP orchestration on top of `idea` + `storage`) | Removes the `idea` ↔ `slides` cycle. `idea → storage` is allowed (Idea delete, `Sequence.md` §5); the forbidden direction is `storage → idea` | Decided |
|
|
153
153
|
| D30 | Split `auth` into `account` and `auth`; `account` defines `SessionRevoker`, `auth` implements it | Smaller modules; Vault uncertainty isolated; no cycle | Decided |
|
|
154
154
|
| D31 | Repositories use `JdbcClient`, hand-written SQL owned by each module, no JPA | Locking, upserts, atomic session statement and visibility query are SQL by design | **Superseded by ADR G1 (sqlc, hand-written SQL per module; intent unchanged)** (was: Decided) |
|
|
155
155
|
| D32 | `shared` depends on no business module and contains no business concept | Reusable, trivially testable kernel | Decided |
|
|
@@ -52,6 +52,7 @@ Same graph and rules as `Backend.md` §2.1. Go mechanics:
|
|
|
52
52
|
- **`SubmissionGate`** (D34, OI-18):
|
|
53
53
|
- `Precheck(ctx, actor) error` — read-only;
|
|
54
54
|
- `AssertOpenInTx(ctx, tx *sql.Tx, actor) error` — the `tx` parameter makes it impossible to call outside a write transaction [ADR G9].
|
|
55
|
+
- **`idea → storage`** (allowed; bd 1ve): `DELETE /ideas/{id}` (`Sequence.md` §5) inserts `pending_file_deletion` in its own transaction and deletes the object after commit, so `idea` needs `storage`. `idea` declares a small interface (for example `ObjectDeleter { Delete(ctx, key string) error }`) that `storage` implements, and `main.go` injects it. `storage` still imports no `idea` (D29), so the graph stays acyclic.
|
|
55
56
|
- Allowed edges are listed once in a table inside the import-graph test (§2.4) and compared with the real graph.
|
|
56
57
|
### 2.2 Package layout inside a module **[ADR G6]**
|
|
57
58
|
|
|
@@ -323,7 +324,7 @@ Same as `Backend.md` §8. The visibility query is one sqlc query in `idea/intern
|
|
|
323
324
|
|
|
324
325
|
Same rules as `Backend.md` §9, including the Vault sync table. Go mechanics **[Proposed]**:
|
|
325
326
|
|
|
326
|
-
- Background work (Vault sync, session and throttle purge) runs as goroutines started in `main.go`
|
|
327
|
+
- Background work (Vault sync, session and throttle purge) runs as goroutines started in `main.go` through `shared/job.Run`: a timer re-armed after each run with the interval plus a fresh random jitter (the first run also waits a random jitter), so replicas drift apart. Each stops when the root context is cancelled at shutdown, and `main` waits for it before closing the pool. No leader is elected, so every job must be idempotent and safe on several replicas at once. The auth purge (B1b-07) runs every 5 min + up to 1 min and deletes in batches of 1000 with `DELETE … LIMIT`; a deadlock or lock-wait timeout ends that table's turn without an error.
|
|
327
328
|
- Readiness waits for the first successful Vault read (`atomic.Bool` read by the readiness handler).
|
|
328
329
|
- The orphan-file claim (`SELECT … FOR UPDATE SKIP LOCKED`) is generated by sqlc as written (spike case c03).
|
|
329
330
|
---
|
package/frontend/Dockerfile
CHANGED
|
@@ -6,6 +6,8 @@ WORKDIR /repo/frontend
|
|
|
6
6
|
COPY frontend/package.json frontend/package-lock.json ./
|
|
7
7
|
RUN npm ci
|
|
8
8
|
COPY testdata /repo/testdata
|
|
9
|
+
# The common-password list lives with the backend rule; the register page lazy-loads the same file.
|
|
10
|
+
COPY backend/internal/account/internal/passwords/common-passwords.txt /repo/backend/internal/account/internal/passwords/
|
|
9
11
|
COPY frontend/ ./
|
|
10
12
|
RUN npm run build
|
|
11
13
|
|