@johnnyboy11234/esg-idea-contest 0.3.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (138) hide show
  1. package/backend/api/openapi.yaml +119 -0
  2. package/backend/cmd/server/main.go +36 -2
  3. package/backend/db/migration/V005__idea.sql +49 -0
  4. package/backend/db/migration/V006__login_lock.sql +15 -0
  5. package/backend/db/migration/V007__login_attempt.sql +15 -0
  6. package/backend/db/migration/V008__pending_file_deletion.sql +27 -0
  7. package/backend/db/migration/V009__login_throttle_audit.sql +13 -0
  8. package/backend/go.mod +17 -3
  9. package/backend/go.sum +48 -7
  10. package/backend/internal/account/account_test.go +6 -6
  11. package/backend/internal/account/integration_test.go +45 -9
  12. package/backend/internal/account/internal/passwords/NOTICE +36 -0
  13. package/backend/internal/account/internal/passwords/common-passwords.txt +10001 -0
  14. package/backend/internal/account/internal/passwords/passwords.go +37 -0
  15. package/backend/internal/account/internal/passwords/passwords_test.go +35 -0
  16. package/backend/internal/account/keyboard.go +75 -0
  17. package/backend/internal/account/keyboard_test.go +43 -0
  18. package/backend/internal/account/logging_integration_test.go +1 -1
  19. package/backend/internal/account/password.go +131 -0
  20. package/backend/internal/account/sequence.go +54 -0
  21. package/backend/internal/account/sequence_test.go +38 -0
  22. package/backend/internal/account/validate.go +4 -21
  23. package/backend/internal/api/api.gen.go +262 -0
  24. package/backend/internal/auth/internal/store/login_attempt.go +53 -0
  25. package/backend/internal/auth/internal/store/login_lock.go +77 -0
  26. package/backend/internal/auth/internal/store/login_throttle_audit.go +42 -0
  27. package/backend/internal/auth/internal/store/session.go +15 -7
  28. package/backend/internal/auth/login.go +16 -4
  29. package/backend/internal/auth/login_integration_test.go +14 -1
  30. package/backend/internal/auth/login_ip_integration_test.go +153 -0
  31. package/backend/internal/auth/login_lock_integration_test.go +205 -0
  32. package/backend/internal/auth/login_test.go +15 -0
  33. package/backend/internal/auth/login_throttle_audit_integration_test.go +88 -0
  34. package/backend/internal/auth/purge.go +93 -0
  35. package/backend/internal/auth/purge_integration_test.go +220 -0
  36. package/backend/internal/auth/purge_test.go +51 -0
  37. package/backend/internal/auth/session.go +0 -6
  38. package/backend/internal/auth/session_integration_test.go +3 -3
  39. package/backend/internal/auth/throttle.go +122 -0
  40. package/backend/internal/deadline/gate.go +80 -0
  41. package/backend/internal/deadline/gate_integration_test.go +67 -0
  42. package/backend/internal/deadline/gate_test.go +61 -0
  43. package/backend/internal/idea/create.go +356 -0
  44. package/backend/internal/idea/create_integration_test.go +680 -0
  45. package/backend/internal/idea/download.go +193 -0
  46. package/backend/internal/idea/download_integration_test.go +311 -0
  47. package/backend/internal/idea/download_test.go +25 -0
  48. package/backend/internal/idea/handler.go +151 -0
  49. package/backend/internal/idea/handler_integration_test.go +107 -0
  50. package/backend/internal/idea/integration_test.go +149 -0
  51. package/backend/internal/idea/internal/store/list.go +146 -0
  52. package/backend/internal/idea/internal/store/list_test.go +33 -0
  53. package/backend/internal/idea/internal/store/store.go +166 -0
  54. package/backend/internal/idea/list.go +166 -0
  55. package/backend/internal/idea/list_integration_test.go +252 -0
  56. package/backend/internal/idea/list_test.go +96 -0
  57. package/backend/internal/idea/service.go +54 -0
  58. package/backend/internal/server/api.go +22 -0
  59. package/backend/internal/server/binding.go +4 -0
  60. package/backend/internal/server/public.go +1 -0
  61. package/backend/internal/server/server.go +20 -1
  62. package/backend/internal/server/slides.go +21 -0
  63. package/backend/internal/server/slides_test.go +82 -0
  64. package/backend/internal/shared/config/config.go +44 -1
  65. package/backend/internal/shared/config/config_test.go +21 -13
  66. package/backend/internal/shared/db/intx_integration_test.go +179 -0
  67. package/backend/internal/shared/dbtest/dbtest.go +21 -3
  68. package/backend/internal/shared/dbtest/faults.go +162 -0
  69. package/backend/internal/shared/job/job.go +54 -0
  70. package/backend/internal/shared/job/job_test.go +105 -0
  71. package/backend/internal/shared/web/multipart.go +175 -0
  72. package/backend/internal/shared/web/multipart_test.go +169 -0
  73. package/backend/internal/shared/web/problem.go +1 -0
  74. package/backend/internal/shared/web/request.go +42 -1
  75. package/backend/internal/shared/web/transport.go +4 -3
  76. package/backend/internal/shared/web/web_test.go +36 -0
  77. package/backend/internal/storage/expectedfilename.go +43 -0
  78. package/backend/internal/storage/expectedfilename_test.go +16 -0
  79. package/backend/internal/storage/internal/store/pending.go +41 -0
  80. package/backend/internal/storage/minio.go +139 -0
  81. package/backend/internal/storage/minio_integration_test.go +87 -0
  82. package/backend/internal/storage/minio_test.go +84 -0
  83. package/backend/internal/storage/pending.go +144 -0
  84. package/backend/internal/storage/pending_integration_test.go +324 -0
  85. package/backend/internal/storage/pending_test.go +23 -0
  86. package/backend/internal/storage/validator.go +100 -0
  87. package/backend/internal/storage/validator_test.go +95 -0
  88. package/backend/test/arch/arch_test.go +5 -1
  89. package/backend/test/contract/expected_filename_test.go +60 -0
  90. package/backend/test/contract/password_test.go +46 -0
  91. package/backend/test/matrix/load_test.go +12 -0
  92. package/backend/test/matrix/matrix_integration_test.go +92 -5
  93. package/backend/test/migration/pending_file_deletion_test.go +46 -0
  94. package/docs/API/api-endpoints.md +1 -1
  95. package/docs/API/error-codes.md +1 -1
  96. package/docs/Architecture/Architecture.md +1 -1
  97. package/docs/Architecture/Backend.md +2 -1
  98. package/frontend/Dockerfile +2 -0
  99. package/frontend/package-lock.json +1 -0
  100. package/frontend/package.json +1 -0
  101. package/frontend/src/app/App.tsx +11 -2
  102. package/frontend/src/app/pages/ideaCreate.test.tsx +308 -0
  103. package/frontend/src/app/pages/ideas.tsx +23 -9
  104. package/frontend/src/app/pages/login.test.tsx +38 -1
  105. package/frontend/src/app/pages/register.test.tsx +32 -0
  106. package/frontend/src/features/auth/IdleWarning.test.tsx +82 -0
  107. package/frontend/src/features/auth/IdleWarning.tsx +66 -0
  108. package/frontend/src/features/auth/LoginForm.tsx +21 -3
  109. package/frontend/src/features/auth/PasswordChecklist.test.tsx +73 -0
  110. package/frontend/src/features/auth/PasswordChecklist.tsx +96 -0
  111. package/frontend/src/features/auth/RegisterForm.tsx +11 -2
  112. package/frontend/src/features/auth/common-passwords.ts +31 -0
  113. package/frontend/src/features/auth/password-policy.test.ts +64 -0
  114. package/frontend/src/features/auth/password-policy.ts +168 -0
  115. package/frontend/src/features/auth/useCountdown.test.tsx +33 -0
  116. package/frontend/src/features/auth/useCountdown.ts +28 -0
  117. package/frontend/src/features/idea/CreateIdeaForm.tsx +279 -0
  118. package/frontend/src/features/idea/create.ts +123 -0
  119. package/frontend/src/shared/api/activity.ts +20 -0
  120. package/frontend/src/shared/api/http.ts +2 -0
  121. package/frontend/src/shared/api/index.ts +1 -0
  122. package/frontend/src/shared/api/types.generated.ts +319 -1
  123. package/frontend/src/shared/i18n/locales/en.ts +46 -4
  124. package/frontend/src/shared/i18n/locales/zh-TW.ts +46 -4
  125. package/frontend/src/shared/slides/index.ts +74 -0
  126. package/frontend/src/shared/slides/slides.test.ts +51 -0
  127. package/frontend/src/shared/ui/copy-button.tsx +52 -0
  128. package/frontend/src/shared/ui/native-select.tsx +27 -0
  129. package/frontend/src/shared/ui/textarea.tsx +19 -0
  130. package/frontend/src/test-setup.ts +37 -2
  131. package/frontend/vite.config.ts +3 -1
  132. package/package.json +1 -1
  133. package/testdata/expected-filename.json +93 -0
  134. package/testdata/password.json +582 -0
  135. package/testdata/permission-matrix.json +71 -0
  136. package/testdata/schema/expected-filename.schema.json +32 -0
  137. package/testdata/schema/password.schema.json +59 -0
  138. package/testdata/schema/permission-matrix.schema.json +22 -1
@@ -0,0 +1,193 @@
1
+ package idea
2
+
3
+ import (
4
+ "context"
5
+ "database/sql"
6
+ "errors"
7
+ "fmt"
8
+ "io"
9
+ "log/slog"
10
+ "net/http"
11
+ "strconv"
12
+ "strings"
13
+
14
+ "github.com/google/uuid"
15
+
16
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/api"
17
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/audit"
18
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/idea/internal/store"
19
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/shared/db"
20
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/shared/security"
21
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/shared/web"
22
+ )
23
+
24
+ // Download is an opened slide file. Body yields exactly Idea.Slides.Size bytes
25
+ // unless storage fails mid-stream; the caller must Close it.
26
+ type Download struct {
27
+ Idea Idea
28
+ Body io.ReadCloser
29
+ }
30
+
31
+ // Download runs GET /ideas/{id}/slides (Sequence.md §6): the visibility query
32
+ // and the SLIDES_DOWNLOAD row share one transaction, and the object is opened
33
+ // only after that transaction has committed, so no byte is served without a
34
+ // durable audit row (fail-closed, PRD 6.11). A missing and a hidden Idea both
35
+ // return NOT_FOUND. An unknown commit outcome serves nothing (503), because
36
+ // the audit row may not exist. Downloads are never state-limited.
37
+ //
38
+ // If storage fails after the commit, the audit row stays for a download that
39
+ // answered 503: accepted, the intent was real (Sequence.md §6).
40
+ func (s *Service) Download(ctx context.Context, id uuid.UUID, caller security.Principal, req Request) (Download, error) {
41
+ var got Idea
42
+ out, err := db.InTx(ctx, s.DB, func(ctx context.Context, tx *sql.Tx) error {
43
+ var err error
44
+ if got, err = store.GetVisible(ctx, tx, id, caller.UserID, caller.IsAdmin()); err != nil {
45
+ return err
46
+ }
47
+ return s.Audit.Record(ctx, tx, audit.Event{
48
+ Action: audit.SlidesDownload, Outcome: audit.Success,
49
+ TargetType: audit.TargetIdea, TargetID: id,
50
+ Method: req.Method, Route: req.Route, Status: http.StatusOK,
51
+ IP: req.IP, UserAgent: req.UserAgent,
52
+ })
53
+ })
54
+ switch {
55
+ case out == db.Unknown:
56
+ e := web.NewError(web.CodeServiceUnavailable, "The download could not be recorded; please retry.")
57
+ e.Cause = err
58
+ return Download{}, e
59
+ case errors.Is(err, store.ErrNotFound):
60
+ return Download{}, web.NewError(web.CodeNotFound, "")
61
+ case err != nil:
62
+ return Download{}, err
63
+ }
64
+
65
+ // Storage outages are *web.Error STORAGE_UNAVAILABLE (503); a missing
66
+ // object is a consistency fault and answers 500.
67
+ body, size, err := s.Objects.Get(ctx, got.Slides.ObjectKey)
68
+ if err != nil {
69
+ return Download{}, fmt.Errorf("open slides of idea %s: %w", id, err)
70
+ }
71
+ // Content-Length is the stored slides_size; an object of another length
72
+ // would make every client see a truncated or overlong body.
73
+ if size != got.Slides.Size {
74
+ _ = body.Close()
75
+ return Download{}, fmt.Errorf("slides of idea %s: object is %d bytes, row says %d", id, size, got.Slides.Size)
76
+ }
77
+ return Download{Idea: got, Body: body}, nil
78
+ }
79
+
80
+ // DownloadSlides serves GET /ideas/{id}/slides: 200 with the file named by the
81
+ // current expected filename (PRD 6.6, 6.8). HEAD never reaches this handler
82
+ // (explicit 405 route, D47/D59).
83
+ func (h Handler) DownloadSlides(w http.ResponseWriter, r *http.Request, id api.IdeaId) {
84
+ p, ok := security.PrincipalFrom(r.Context())
85
+ if !ok {
86
+ h.Fail(w, r, web.NewError(web.CodeUnauthenticated, "authentication required"))
87
+ return
88
+ }
89
+ d, err := h.Service.Download(r.Context(), uuid.UUID(id), p,
90
+ Request{Method: r.Method, Route: web.RoutePattern(r), UserAgent: r.UserAgent(), IP: web.ClientIP(r)})
91
+ if err != nil {
92
+ var e *web.Error
93
+ switch {
94
+ case errors.As(err, &e) && e.Code == web.CodeNotFound:
95
+ e.AuditAction = string(audit.SlidesDownload)
96
+ e.AuditReason = auditReasonHidden
97
+ e.AuditTargetType = string(audit.TargetIdea)
98
+ e.AuditTargetID = uuid.UUID(id)
99
+ case e == nil:
100
+ h.log().ErrorContext(r.Context(), "slides download failed", "ideaId", uuid.UUID(id).String(), "error", err)
101
+ }
102
+ h.Fail(w, r, err)
103
+ return
104
+ }
105
+ defer func() { _ = d.Body.Close() }()
106
+
107
+ i := d.Idea
108
+ base, err := h.ExpectedFilename(i.Category, i.Org, i.LeaderName, i.LeaderEmpNo, i.Name)
109
+ if err != nil {
110
+ h.Fail(w, r, err)
111
+ return
112
+ }
113
+ hd := w.Header()
114
+ hd.Set("Content-Type", contentTypes[i.Slides.Ext])
115
+ hd.Set("Content-Length", strconv.FormatInt(i.Slides.Size, 10))
116
+ hd.Set("Content-Disposition", ContentDisposition(base+"."+i.Slides.Ext))
117
+ hd.Set("Cache-Control", "no-store")
118
+ hd.Set("X-Content-Type-Options", "nosniff")
119
+ w.WriteHeader(http.StatusOK)
120
+
121
+ src := &readErrRecorder{r: d.Body}
122
+ n, err := io.CopyN(w, src, i.Slides.Size)
123
+ if err == nil {
124
+ return
125
+ }
126
+ if src.err != nil || errors.Is(err, io.EOF) {
127
+ // Headers are out, so no error body can follow. Abort the connection
128
+ // (HTTP/1.1 close, HTTP/2 RST_STREAM): with Content-Length set the
129
+ // client sees an incomplete body instead of a silently short file.
130
+ h.log().ErrorContext(r.Context(), "slides download aborted: storage failed mid-stream",
131
+ "ideaId", i.ID.String(), "sent", n, "size", i.Slides.Size, "error", err)
132
+ panic(http.ErrAbortHandler)
133
+ }
134
+ // The client went away; nothing left to tell it.
135
+ h.log().InfoContext(r.Context(), "slides download interrupted by client",
136
+ "ideaId", i.ID.String(), "sent", n, "size", i.Slides.Size, "error", err)
137
+ }
138
+
139
+ func (h Handler) log() *slog.Logger {
140
+ if h.Log == nil {
141
+ return slog.New(slog.DiscardHandler)
142
+ }
143
+ return h.Log
144
+ }
145
+
146
+ // readErrRecorder remembers the source's first non-EOF error, so a failed copy
147
+ // can be blamed on storage (abort) or on the client (just stop).
148
+ type readErrRecorder struct {
149
+ r io.Reader
150
+ err error
151
+ }
152
+
153
+ func (x *readErrRecorder) Read(p []byte) (int, error) {
154
+ n, err := x.r.Read(p)
155
+ if err != nil && !errors.Is(err, io.EOF) && x.err == nil {
156
+ x.err = err
157
+ }
158
+ return n, err
159
+ }
160
+
161
+ // ContentDisposition is the RFC 6266 attachment header for name: an ASCII
162
+ // fallback filename (non-ASCII, quotes, backslashes and controls become "_")
163
+ // and the exact UTF-8 name as filename* (RFC 8187 percent-encoding).
164
+ func ContentDisposition(name string) string {
165
+ var fallback, ext strings.Builder
166
+ for _, r := range name {
167
+ if r < 0x20 || r > 0x7e || r == '"' || r == '\\' {
168
+ fallback.WriteByte('_')
169
+ } else {
170
+ fallback.WriteRune(r)
171
+ }
172
+ }
173
+ const hex = "0123456789ABCDEF"
174
+ for _, b := range []byte(name) {
175
+ if isAttrChar(b) {
176
+ ext.WriteByte(b)
177
+ } else {
178
+ ext.WriteByte('%')
179
+ ext.WriteByte(hex[b>>4])
180
+ ext.WriteByte(hex[b&0x0f])
181
+ }
182
+ }
183
+ return `attachment; filename="` + fallback.String() + `"; filename*=UTF-8''` + ext.String()
184
+ }
185
+
186
+ // isAttrChar is RFC 8187 attr-char: ALPHA / DIGIT / "!#$&+-.^_`|~".
187
+ func isAttrChar(b byte) bool {
188
+ switch {
189
+ case b >= 'a' && b <= 'z', b >= 'A' && b <= 'Z', b >= '0' && b <= '9':
190
+ return true
191
+ }
192
+ return strings.IndexByte("!#$&+-.^_`|~", b) >= 0
193
+ }
@@ -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
+ }
@@ -0,0 +1,151 @@
1
+ package idea
2
+
3
+ import (
4
+ "context"
5
+ "database/sql"
6
+ "errors"
7
+ "log/slog"
8
+ "net/http"
9
+
10
+ "github.com/google/uuid"
11
+
12
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/api"
13
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/audit"
14
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/idea/internal/store"
15
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/shared/db"
16
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/shared/security"
17
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/shared/web"
18
+ )
19
+
20
+ // auditReasonHidden is the one reason stored for a missing and a hidden Idea
21
+ // alike: the log must not tell them apart either (PRD 6.6).
22
+ const auditReasonHidden = "NOT_FOUND"
23
+
24
+ // View returns the Idea for GET /ideas/{id}. An Admin's successful view writes
25
+ // IDEA_VIEW in the same transaction as the read (fail-closed, PRD 6.11).
26
+ func (s *Service) View(ctx context.Context, id uuid.UUID, caller security.Principal) (Idea, error) {
27
+ if !caller.IsAdmin() {
28
+ return s.Get(ctx, id, caller)
29
+ }
30
+ var got Idea
31
+ _, err := db.InTx(ctx, s.DB, func(ctx context.Context, tx *sql.Tx) error {
32
+ var err error
33
+ if got, err = store.GetVisible(ctx, tx, id, caller.UserID, true); err != nil {
34
+ return err
35
+ }
36
+ return s.Audit.Record(ctx, tx, audit.Event{
37
+ Action: audit.IdeaView, Outcome: audit.Success,
38
+ TargetType: audit.TargetIdea, TargetID: id,
39
+ })
40
+ })
41
+ if errors.Is(err, store.ErrNotFound) {
42
+ return Idea{}, web.NewError(web.CodeNotFound, "")
43
+ }
44
+ return got, err
45
+ }
46
+
47
+ // Handler serves the Idea endpoints.
48
+ type Handler struct {
49
+ Service *Service
50
+ // ExpectedFilename builds the expected slide filename without extension
51
+ // (storage.ExpectedFilenameBase; injected because idea must not import
52
+ // storage, D29).
53
+ ExpectedFilename func(category, org, leaderName, leaderEmpNo, ideaName string) (string, error)
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
57
+ }
58
+
59
+ type person struct {
60
+ EmpNo string `json:"empNo"`
61
+ Name string `json:"name"`
62
+ }
63
+
64
+ type member struct {
65
+ EmpNo string `json:"empNo"`
66
+ Name string `json:"name"`
67
+ JoinedAt string `json:"joinedAt"`
68
+ }
69
+
70
+ type pending struct {
71
+ EmpNo string `json:"empNo"`
72
+ Name string `json:"name"`
73
+ RequestedAt string `json:"requestedAt"`
74
+ }
75
+
76
+ type slides struct {
77
+ Ext string `json:"ext"`
78
+ Size int64 `json:"size"`
79
+ SHA256 string `json:"sha256"`
80
+ UpdatedAt string `json:"updatedAt"`
81
+ DownloadFilename string `json:"downloadFilename"`
82
+ }
83
+
84
+ // detail is IdeaDetail (docs/API/response-bodies.md). It is local so that
85
+ // timestamps use web.FormatTime (fixed milliseconds) instead of time.Time.
86
+ type detail struct {
87
+ ID uuid.UUID `json:"id"`
88
+ Category string `json:"category"`
89
+ Org string `json:"org"`
90
+ Direction string `json:"direction"`
91
+ Name string `json:"name"`
92
+ Summary string `json:"summary"`
93
+ Leader person `json:"leader"`
94
+ Members []member `json:"members"`
95
+ PendingRequests *[]pending `json:"pendingRequests"`
96
+ Slides slides `json:"slides"`
97
+ ExpectedFilenameBase string `json:"expectedFilenameBase"`
98
+ CreatedAt string `json:"createdAt"`
99
+ UpdatedAt string `json:"updatedAt"`
100
+ Version int64 `json:"version"`
101
+ }
102
+
103
+ // GetIdea serves GET /ideas/{id}.
104
+ func (h Handler) GetIdea(w http.ResponseWriter, r *http.Request, id api.IdeaId) {
105
+ p, ok := security.PrincipalFrom(r.Context())
106
+ if !ok {
107
+ h.Fail(w, r, web.NewError(web.CodeUnauthenticated, "authentication required"))
108
+ return
109
+ }
110
+ got, err := h.Service.View(r.Context(), uuid.UUID(id), p)
111
+ if err != nil {
112
+ var e *web.Error
113
+ if errors.As(err, &e) && e.Code == web.CodeNotFound {
114
+ e.AuditAction = string(audit.IdeaView)
115
+ e.AuditReason = auditReasonHidden
116
+ e.AuditTargetType = string(audit.TargetIdea)
117
+ e.AuditTargetID = uuid.UUID(id)
118
+ }
119
+ h.Fail(w, r, err)
120
+ return
121
+ }
122
+ body, err := h.toDetail(got, p)
123
+ if err != nil {
124
+ h.Fail(w, r, err)
125
+ return
126
+ }
127
+ web.WriteJSON(w, http.StatusOK, body)
128
+ }
129
+
130
+ func (h Handler) toDetail(i Idea, caller security.Principal) (detail, error) {
131
+ base, err := h.ExpectedFilename(i.Category, i.Org, i.LeaderName, i.LeaderEmpNo, i.Name)
132
+ if err != nil {
133
+ return detail{}, err
134
+ }
135
+ d := detail{
136
+ ID: i.ID, Category: i.Category, Org: i.Org, Direction: i.Direction, Name: i.Name, Summary: i.Summary,
137
+ Leader: person{EmpNo: i.LeaderEmpNo, Name: i.LeaderName},
138
+ // TODO(B5-01): approved Members and pending requests come from idea_member.
139
+ Members: []member{},
140
+ Slides: slides{Ext: i.Slides.Ext, Size: i.Slides.Size, SHA256: i.Slides.SHA256,
141
+ UpdatedAt: web.FormatTime(i.SlidesUpdatedAt), DownloadFilename: base + "." + i.Slides.Ext},
142
+ ExpectedFilenameBase: base,
143
+ CreatedAt: web.FormatTime(i.CreatedAt),
144
+ UpdatedAt: web.FormatTime(i.UpdatedAt),
145
+ Version: int64(i.Version),
146
+ }
147
+ if caller.IsAdmin() || caller.UserID == i.LeaderID {
148
+ d.PendingRequests = &[]pending{}
149
+ }
150
+ return d, nil
151
+ }