@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.
Files changed (58) hide show
  1. package/backend/api/openapi.yaml +36 -0
  2. package/backend/cmd/server/main.go +1 -0
  3. package/backend/internal/account/internal/passwords/NOTICE +36 -0
  4. package/backend/internal/account/internal/passwords/common-passwords.txt +10001 -0
  5. package/backend/internal/account/internal/passwords/passwords.go +37 -0
  6. package/backend/internal/account/internal/passwords/passwords_test.go +35 -0
  7. package/backend/internal/account/password.go +8 -2
  8. package/backend/internal/account/sequence.go +54 -0
  9. package/backend/internal/account/sequence_test.go +38 -0
  10. package/backend/internal/api/api.gen.go +38 -0
  11. package/backend/internal/idea/create.go +5 -1
  12. package/backend/internal/idea/create_integration_test.go +33 -3
  13. package/backend/internal/idea/download.go +193 -0
  14. package/backend/internal/idea/download_integration_test.go +311 -0
  15. package/backend/internal/idea/download_test.go +25 -0
  16. package/backend/internal/idea/handler.go +3 -0
  17. package/backend/internal/idea/service.go +2 -1
  18. package/backend/internal/server/api.go +5 -0
  19. package/backend/internal/server/binding.go +4 -0
  20. package/backend/internal/server/server.go +20 -1
  21. package/backend/internal/server/slides_test.go +82 -0
  22. package/backend/internal/shared/web/transport.go +4 -3
  23. package/backend/test/arch/arch_test.go +5 -1
  24. package/backend/test/matrix/matrix_integration_test.go +6 -0
  25. package/docs/Architecture/Architecture.md +1 -1
  26. package/docs/Architecture/Backend.md +2 -1
  27. package/frontend/Dockerfile +2 -0
  28. package/frontend/package-lock.json +1 -0
  29. package/frontend/package.json +1 -0
  30. package/frontend/src/app/pages/ideaCreate.test.tsx +308 -0
  31. package/frontend/src/app/pages/ideas.tsx +23 -9
  32. package/frontend/src/app/pages/login.test.tsx +38 -1
  33. package/frontend/src/app/pages/register.test.tsx +32 -0
  34. package/frontend/src/features/auth/LoginForm.tsx +21 -3
  35. package/frontend/src/features/auth/PasswordChecklist.test.tsx +73 -0
  36. package/frontend/src/features/auth/PasswordChecklist.tsx +96 -0
  37. package/frontend/src/features/auth/RegisterForm.tsx +11 -2
  38. package/frontend/src/features/auth/common-passwords.ts +31 -0
  39. package/frontend/src/features/auth/password-policy.test.ts +64 -0
  40. package/frontend/src/features/auth/password-policy.ts +168 -0
  41. package/frontend/src/features/auth/useCountdown.test.tsx +33 -0
  42. package/frontend/src/features/auth/useCountdown.ts +28 -0
  43. package/frontend/src/features/idea/CreateIdeaForm.tsx +279 -0
  44. package/frontend/src/features/idea/create.ts +123 -0
  45. package/frontend/src/shared/api/types.generated.ts +319 -1
  46. package/frontend/src/shared/i18n/locales/en.ts +45 -4
  47. package/frontend/src/shared/i18n/locales/zh-TW.ts +45 -4
  48. package/frontend/src/shared/slides/index.ts +74 -0
  49. package/frontend/src/shared/slides/slides.test.ts +51 -0
  50. package/frontend/src/shared/ui/copy-button.tsx +52 -0
  51. package/frontend/src/shared/ui/native-select.tsx +27 -0
  52. package/frontend/src/shared/ui/textarea.tsx +19 -0
  53. package/frontend/src/test-setup.ts +37 -2
  54. package/frontend/vite.config.ts +3 -1
  55. package/package.json +1 -1
  56. package/testdata/password.json +164 -7
  57. package/testdata/permission-matrix.json +23 -0
  58. 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, Objects and Aborter are needed by Create only.
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. Slide download routes opt out of this check in their PR.
24
- func AcceptCheck(next http.Handler) http.Handler {
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` with a `time.Ticker` plus random jitter; each stops when the root context is cancelled at shutdown.
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
  ---
@@ -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
 
@@ -46,6 +46,7 @@
46
46
  "tailwindcss": "^4.3.5",
47
47
  "typescript": "~6.0",
48
48
  "typescript-eslint": "^8.72.1",
49
+ "undici": "^8.12.4",
49
50
  "vite": "^8.3.6",
50
51
  "vitest": "^5.1.0",
51
52
  "vitest-axe": "^0.1.0"
@@ -54,6 +54,7 @@
54
54
  "tailwindcss": "^4.3.5",
55
55
  "typescript": "~6.0",
56
56
  "typescript-eslint": "^8.72.1",
57
+ "undici": "^8.12.4",
57
58
  "vite": "^8.3.6",
58
59
  "vitest": "^5.1.0",
59
60
  "vitest-axe": "^0.1.0"