@johnnyboy11234/esg-idea-contest 0.2.4 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/backend/api/openapi.yaml +83 -0
  2. package/backend/cmd/server/main.go +35 -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/keyboard.go +75 -0
  13. package/backend/internal/account/keyboard_test.go +43 -0
  14. package/backend/internal/account/logging_integration_test.go +1 -1
  15. package/backend/internal/account/password.go +125 -0
  16. package/backend/internal/account/validate.go +4 -21
  17. package/backend/internal/api/api.gen.go +224 -0
  18. package/backend/internal/auth/internal/store/login_attempt.go +53 -0
  19. package/backend/internal/auth/internal/store/login_lock.go +77 -0
  20. package/backend/internal/auth/internal/store/login_throttle_audit.go +42 -0
  21. package/backend/internal/auth/internal/store/session.go +15 -7
  22. package/backend/internal/auth/login.go +16 -4
  23. package/backend/internal/auth/login_integration_test.go +14 -1
  24. package/backend/internal/auth/login_ip_integration_test.go +153 -0
  25. package/backend/internal/auth/login_lock_integration_test.go +205 -0
  26. package/backend/internal/auth/login_test.go +15 -0
  27. package/backend/internal/auth/login_throttle_audit_integration_test.go +88 -0
  28. package/backend/internal/auth/purge.go +93 -0
  29. package/backend/internal/auth/purge_integration_test.go +220 -0
  30. package/backend/internal/auth/purge_test.go +51 -0
  31. package/backend/internal/auth/session.go +0 -6
  32. package/backend/internal/auth/session_integration_test.go +3 -3
  33. package/backend/internal/auth/throttle.go +122 -0
  34. package/backend/internal/deadline/gate.go +80 -0
  35. package/backend/internal/deadline/gate_integration_test.go +67 -0
  36. package/backend/internal/deadline/gate_test.go +61 -0
  37. package/backend/internal/idea/create.go +352 -0
  38. package/backend/internal/idea/create_integration_test.go +650 -0
  39. package/backend/internal/idea/handler.go +148 -0
  40. package/backend/internal/idea/handler_integration_test.go +107 -0
  41. package/backend/internal/idea/integration_test.go +149 -0
  42. package/backend/internal/idea/internal/store/list.go +146 -0
  43. package/backend/internal/idea/internal/store/list_test.go +33 -0
  44. package/backend/internal/idea/internal/store/store.go +166 -0
  45. package/backend/internal/idea/list.go +166 -0
  46. package/backend/internal/idea/list_integration_test.go +252 -0
  47. package/backend/internal/idea/list_test.go +96 -0
  48. package/backend/internal/idea/service.go +53 -0
  49. package/backend/internal/server/api.go +17 -0
  50. package/backend/internal/server/public.go +1 -0
  51. package/backend/internal/server/slides.go +21 -0
  52. package/backend/internal/shared/config/config.go +44 -1
  53. package/backend/internal/shared/config/config_test.go +21 -13
  54. package/backend/internal/shared/db/intx_integration_test.go +179 -0
  55. package/backend/internal/shared/dbtest/dbtest.go +21 -3
  56. package/backend/internal/shared/dbtest/faults.go +162 -0
  57. package/backend/internal/shared/job/job.go +54 -0
  58. package/backend/internal/shared/job/job_test.go +105 -0
  59. package/backend/internal/shared/web/multipart.go +175 -0
  60. package/backend/internal/shared/web/multipart_test.go +169 -0
  61. package/backend/internal/shared/web/problem.go +1 -0
  62. package/backend/internal/shared/web/request.go +42 -1
  63. package/backend/internal/shared/web/web_test.go +36 -0
  64. package/backend/internal/storage/expectedfilename.go +43 -0
  65. package/backend/internal/storage/expectedfilename_test.go +16 -0
  66. package/backend/internal/storage/internal/store/pending.go +41 -0
  67. package/backend/internal/storage/minio.go +139 -0
  68. package/backend/internal/storage/minio_integration_test.go +87 -0
  69. package/backend/internal/storage/minio_test.go +84 -0
  70. package/backend/internal/storage/pending.go +144 -0
  71. package/backend/internal/storage/pending_integration_test.go +324 -0
  72. package/backend/internal/storage/pending_test.go +23 -0
  73. package/backend/internal/storage/validator.go +100 -0
  74. package/backend/internal/storage/validator_test.go +95 -0
  75. package/backend/test/contract/expected_filename_test.go +60 -0
  76. package/backend/test/contract/password_test.go +46 -0
  77. package/backend/test/matrix/file_test.go +52 -0
  78. package/backend/test/matrix/load_test.go +62 -0
  79. package/backend/test/matrix/matrix_integration_test.go +291 -0
  80. package/backend/test/migration/pending_file_deletion_test.go +46 -0
  81. package/docs/API/api-endpoints.md +1 -1
  82. package/docs/API/error-codes.md +1 -1
  83. package/frontend/public/auth-hero-login.jpg +0 -0
  84. package/frontend/public/auth-hero-register.jpg +0 -0
  85. package/frontend/src/app/App.test.tsx +2 -42
  86. package/frontend/src/app/App.tsx +11 -2
  87. package/frontend/src/app/index.css +4 -4
  88. package/frontend/src/app/layouts.tsx +7 -135
  89. package/frontend/src/app/pages/JoinRequests.tsx +2 -2
  90. package/frontend/src/app/pages/NotFound.tsx +1 -1
  91. package/frontend/src/app/pages/admin.tsx +1 -1
  92. package/frontend/src/app/pages/auth.tsx +53 -25
  93. package/frontend/src/app/pages/ideas.tsx +7 -7
  94. package/frontend/src/app/pages/login.test.tsx +3 -54
  95. package/frontend/src/app/pages/register.test.tsx +1 -1
  96. package/frontend/src/app/router.tsx +2 -6
  97. package/frontend/src/features/auth/IdleWarning.test.tsx +82 -0
  98. package/frontend/src/features/auth/IdleWarning.tsx +66 -0
  99. package/frontend/src/features/auth/LoginForm.tsx +8 -1
  100. package/frontend/src/shared/api/activity.ts +20 -0
  101. package/frontend/src/shared/api/http.ts +2 -0
  102. package/frontend/src/shared/api/index.ts +1 -0
  103. package/frontend/src/shared/i18n/locales/en.ts +1 -0
  104. package/frontend/src/shared/i18n/locales/zh-TW.ts +1 -0
  105. package/package.json +1 -1
  106. package/testdata/expected-filename.json +93 -0
  107. package/testdata/password.json +425 -0
  108. package/testdata/permission-matrix.json +157 -0
  109. package/testdata/schema/expected-filename.schema.json +32 -0
  110. package/testdata/schema/password.schema.json +59 -0
  111. package/testdata/schema/permission-matrix.schema.json +73 -0
@@ -0,0 +1,52 @@
1
+ package matrix
2
+
3
+ import (
4
+ "encoding/json"
5
+ "os"
6
+ "path/filepath"
7
+ "slices"
8
+ "testing"
9
+ )
10
+
11
+ // The data file itself is checked without Docker: ids are unique, actors are
12
+ // known, and every expected code exists in the shared error catalogue with the
13
+ // status the catalogue gives it.
14
+ func TestMatrixFileIsConsistent(t *testing.T) {
15
+ raw, err := os.ReadFile(filepath.Join(testdata, "error-codes.json"))
16
+ if err != nil {
17
+ t.Fatal(err)
18
+ }
19
+ var cat []struct {
20
+ Code string
21
+ Status int
22
+ }
23
+ if err := json.Unmarshal(raw, &cat); err != nil {
24
+ t.Fatal(err)
25
+ }
26
+ status := map[string]int{}
27
+ for _, c := range cat {
28
+ status[c.Code] = c.Status
29
+ }
30
+
31
+ seen := map[string]bool{}
32
+ for _, r := range load(t) {
33
+ if seen[r.ID] {
34
+ t.Errorf("duplicate row id %q", r.ID)
35
+ }
36
+ seen[r.ID] = true
37
+ for actor, c := range r.Expect {
38
+ if !slices.Contains(Actors, actor) {
39
+ t.Errorf("%s: unknown actor %q", r.ID, actor)
40
+ }
41
+ if c.Code == "" {
42
+ if c.Status >= 400 {
43
+ t.Errorf("%s/%s: %d needs a code", r.ID, actor, c.Status)
44
+ }
45
+ continue
46
+ }
47
+ if want, ok := status[c.Code]; !ok || want != c.Status {
48
+ t.Errorf("%s/%s: %s is %d in the catalogue, row says %d", r.ID, actor, c.Code, want, c.Status)
49
+ }
50
+ }
51
+ }
52
+ }
@@ -0,0 +1,62 @@
1
+ // Package matrix runs the permission matrix (PRD §7) from
2
+ // testdata/permission-matrix.json: every cell is one test case.
3
+ package matrix
4
+
5
+ import (
6
+ "bytes"
7
+ "encoding/json"
8
+ "os"
9
+ "path/filepath"
10
+ "testing"
11
+ )
12
+
13
+ const testdata = "../../../testdata"
14
+
15
+ // Cell is the expected outcome of one (row, actor) pair.
16
+ type Cell struct {
17
+ Status int `json:"status"`
18
+ Code string `json:"code"`
19
+ }
20
+
21
+ // Row is one operation or check-order case.
22
+ type Row struct {
23
+ ID string `json:"id"`
24
+ Operation string `json:"operation"`
25
+ Method string `json:"method"`
26
+ Path string `json:"path"`
27
+ Body json.RawMessage `json:"body"`
28
+ Multipart *Multipart `json:"multipart"`
29
+ CSRF string `json:"csrf"`
30
+ State string `json:"state"`
31
+ OutsideSpec bool `json:"outsideSpec"`
32
+ Expect map[string]Cell `json:"expect"`
33
+ }
34
+
35
+ // Multipart is a multipart/form-data body: text parts and one file part.
36
+ type Multipart struct {
37
+ Fields map[string]string `json:"fields"`
38
+ File struct {
39
+ Field string `json:"field"`
40
+ Filename string `json:"filename"`
41
+ Content string `json:"content"` // "pdf": a minimal valid PDF
42
+ } `json:"file"`
43
+ }
44
+
45
+ // Actors in PRD §7 order. The harness has a fixture for each one it lists in
46
+ // fixtures; a row naming any other actor fails loudly instead of passing.
47
+ var Actors = []string{"visitor", "user", "member", "leader", "admin"}
48
+
49
+ func load(tb testing.TB) []Row {
50
+ tb.Helper()
51
+ raw, err := os.ReadFile(filepath.Join(testdata, "permission-matrix.json"))
52
+ if err != nil {
53
+ tb.Fatal(err)
54
+ }
55
+ var m struct{ Rows []Row }
56
+ dec := json.NewDecoder(bytes.NewReader(raw))
57
+ dec.DisallowUnknownFields()
58
+ if err := dec.Decode(&m); err != nil {
59
+ tb.Fatalf("permission-matrix.json: %v", err)
60
+ }
61
+ return m.Rows
62
+ }
@@ -0,0 +1,291 @@
1
+ //go:build integration
2
+
3
+ package matrix
4
+
5
+ import (
6
+ "bytes"
7
+ "context"
8
+ "database/sql"
9
+ "encoding/json"
10
+ "fmt"
11
+ "io"
12
+ "log/slog"
13
+ "mime/multipart"
14
+ "net/http"
15
+ "net/http/httptest"
16
+ "strings"
17
+ "sync/atomic"
18
+ "testing"
19
+
20
+ "github.com/google/uuid"
21
+ "golang.org/x/crypto/bcrypt"
22
+
23
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/account"
24
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/audit"
25
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/auth"
26
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/deadline"
27
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/idea"
28
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/server"
29
+ shareddb "github.com/brian-huanggg/AwardRegistration/backend/internal/shared/db"
30
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/shared/dbtest"
31
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/storage"
32
+ "github.com/brian-huanggg/AwardRegistration/backend/test/apispec"
33
+ )
34
+
35
+ const password = "Correct-Horse-1"
36
+
37
+ type env struct {
38
+ db *sql.DB
39
+ plain http.Handler // production chain
40
+ checked http.Handler // same chain, every response validated against openapi.yaml
41
+ seq atomic.Int64
42
+ }
43
+
44
+ // newEnv wires the same chain as cmd/esg: this harness must fail if the
45
+ // middleware order in production changes, so it must not build its own.
46
+ func newEnv(t *testing.T) *env {
47
+ t.Helper()
48
+ db := dbtest.New(t)
49
+ log := slog.New(slog.DiscardHandler)
50
+ auditSvc := audit.New(db, log)
51
+ errs := server.ErrorAdapter{Audit: auditSvc, Log: log}
52
+ csrf, err := auth.NewCSRF(strings.Repeat("k", 32))
53
+ if err != nil {
54
+ t.Fatal(err)
55
+ }
56
+ sessions := auth.New(db)
57
+ logins, err := auth.NewLogins(sessions, auditSvc, auth.NoThrottle{})
58
+ if err != nil {
59
+ t.Fatal(err)
60
+ }
61
+ si := server.API{
62
+ Auth: auth.Handler{CSRF: csrf, Log: log},
63
+ Logins: auth.LoginHandler{Logins: logins, Fail: errs.Write},
64
+ Logouts: auth.LogoutHandler{Logouts: &auth.Logouts{Sessions: sessions, Audit: auditSvc}, Fail: errs.Write},
65
+ Me: auth.MeHandler{Sessions: sessions, Fail: errs.Write},
66
+ Account: account.Handler{
67
+ Service: &account.Service{DB: db, Audit: auditSvc, Policy: account.DefaultPolicy()},
68
+ Fail: errs.Write,
69
+ },
70
+ Deadline: deadline.Handler{Store: deadline.SQLStore{DB: db}},
71
+ Ideas: idea.Handler{
72
+ Service: &idea.Service{
73
+ DB: db, Audit: auditSvc,
74
+ Gate: deadline.Gate{Store: deadline.SQLStore{DB: db}},
75
+ Slides: server.IdeaSlideRules(),
76
+ Objects: discardObjects{},
77
+ Aborter: discardObjects{},
78
+ },
79
+ ExpectedFilename: storage.ExpectedFilenameBase,
80
+ Fail: errs.Write,
81
+ },
82
+ }
83
+ chain := server.NewPublic(log, server.Mount(si),
84
+ auth.Middleware(sessions, server.IsPublic, log),
85
+ csrf.Middleware(errs.Write, server.ActionFor))
86
+ return &env{db: db, plain: chain, checked: apispec.Load(t).Validate(t, chain)}
87
+ }
88
+
89
+ // discardObjects stands in for MinIO: the matrix is about who may call what,
90
+ // not about storage.
91
+ type discardObjects struct{}
92
+
93
+ func (discardObjects) Put(_ context.Context, _ string, r io.Reader, _ int64, _ string) error {
94
+ _, err := io.Copy(io.Discard, r)
95
+ return err
96
+ }
97
+
98
+ func (discardObjects) AbortUpload(context.Context, string, shareddb.Outcome) {}
99
+
100
+ // stateDeadline is the deadline expression that puts the contest in a state.
101
+ var stateDeadline = map[string]string{
102
+ "NOT_OPEN": "NULL",
103
+ "OPEN": "NOW(6) + INTERVAL 1 DAY",
104
+ "CLOSED": "NOW(6) - INTERVAL 1 DAY",
105
+ }
106
+
107
+ func (e *env) setState(t *testing.T, state string) {
108
+ t.Helper()
109
+ expr, ok := stateDeadline[state]
110
+ if !ok {
111
+ t.Fatalf("unknown state %q", state)
112
+ }
113
+ if _, err := e.db.Exec(`UPDATE contest_setting SET deadline = ` + expr + ` WHERE id = 1`); err != nil {
114
+ t.Fatal(err)
115
+ }
116
+ }
117
+
118
+ func (e *env) do(h http.Handler, req *http.Request) *httptest.ResponseRecorder {
119
+ rec := httptest.NewRecorder()
120
+ h.ServeHTTP(rec, req)
121
+ return rec
122
+ }
123
+
124
+ func (e *env) addUser(t *testing.T, empNo, role string) {
125
+ t.Helper()
126
+ hash, err := bcrypt.GenerateFromPassword([]byte(password), 10)
127
+ if err != nil {
128
+ t.Fatal(err)
129
+ }
130
+ var email any = empNo + "@example.com"
131
+ if role == "ADMIN" {
132
+ email = nil
133
+ }
134
+ if _, err := e.db.Exec(`INSERT INTO app_user (id, emp_no, name, email, password_hash, role, enabled) VALUES (?,?,?,?,?,?,?)`,
135
+ uuid.NewString(), empNo, "N "+empNo, email, string(hash), role, true); err != nil {
136
+ t.Fatal(err)
137
+ }
138
+ }
139
+
140
+ // actor is one caller of the matrix: its employee number, account name and,
141
+ // when logged in, its session cookie.
142
+ type actor struct {
143
+ empNo string
144
+ name string
145
+ session *http.Cookie
146
+ }
147
+
148
+ // fixtures lists the actors the harness can build. Idea-related actors (member,
149
+ // leader) arrive with the PRs that create Ideas.
150
+ var fixtures = map[string]string{"visitor": "", "user": "USER", "admin": "ADMIN"}
151
+
152
+ // newActor creates a fresh account (logout and login change session state, so
153
+ // no two cells share one) and logs in through the API.
154
+ func (e *env) newActor(t *testing.T, name string) actor {
155
+ t.Helper()
156
+ role, ok := fixtures[name]
157
+ if !ok {
158
+ t.Fatalf("matrix actor %q has no fixture; add one before adding cells for it", name)
159
+ }
160
+ if role == "" {
161
+ return actor{empNo: "000000"}
162
+ }
163
+ empNo := fmt.Sprintf("%06d", 100000+e.seq.Add(1))
164
+ e.addUser(t, empNo, role)
165
+ tok, ck := e.csrf(t, nil)
166
+ body := fmt.Sprintf(`{"empNo":%q,"password":%q}`, empNo, password)
167
+ req := httptest.NewRequest("POST", "/api/v1/auth/login", strings.NewReader(body))
168
+ req.Header.Set("Content-Type", "application/json")
169
+ req.Header.Set(auth.CSRFHeader, tok)
170
+ req.AddCookie(ck)
171
+ rec := e.do(e.plain, req)
172
+ if rec.Code != 200 {
173
+ t.Fatalf("login %s: %d %s", name, rec.Code, rec.Body)
174
+ }
175
+ return actor{empNo: empNo, name: "N " + empNo, session: cookie(rec, auth.SessionCookie)}
176
+ }
177
+
178
+ func cookie(rec *httptest.ResponseRecorder, name string) *http.Cookie {
179
+ for _, c := range rec.Result().Cookies() {
180
+ if c.Name == name {
181
+ return c
182
+ }
183
+ }
184
+ return nil
185
+ }
186
+
187
+ func (e *env) csrf(t *testing.T, session *http.Cookie) (string, *http.Cookie) {
188
+ t.Helper()
189
+ req := httptest.NewRequest("GET", "/api/v1/auth/csrf", nil)
190
+ if session != nil {
191
+ req.AddCookie(session)
192
+ }
193
+ rec := e.do(e.plain, req)
194
+ var b struct{ Token string }
195
+ if err := json.Unmarshal(rec.Body.Bytes(), &b); err != nil || rec.Code != 200 {
196
+ t.Fatalf("csrf: %d %s", rec.Code, rec.Body)
197
+ }
198
+ return b.Token, cookie(rec, auth.CSRFCookie)
199
+ }
200
+
201
+ func (e *env) request(t *testing.T, r Row, a actor) *http.Request {
202
+ t.Helper()
203
+ var body *strings.Reader
204
+ if r.Body != nil {
205
+ s := string(r.Body)
206
+ s = strings.ReplaceAll(s, "${password}", password)
207
+ s = strings.ReplaceAll(s, "${actorEmpNo}", a.empNo)
208
+ s = strings.ReplaceAll(s, "${newEmpNo}", fmt.Sprintf("%06d", 500000+e.seq.Add(1)))
209
+ body = strings.NewReader(s)
210
+ }
211
+ var req *http.Request
212
+ if r.Multipart != nil {
213
+ buf, ct := multipartBody(t, r.Multipart, a)
214
+ req = httptest.NewRequest(r.Method, r.Path, buf)
215
+ req.Header.Set("Content-Type", ct)
216
+ } else if body != nil {
217
+ req = httptest.NewRequest(r.Method, r.Path, body)
218
+ req.Header.Set("Content-Type", "application/json")
219
+ } else {
220
+ req = httptest.NewRequest(r.Method, r.Path, nil)
221
+ }
222
+ req.RemoteAddr = "192.0.2.9:4000"
223
+ req.Header.Set("User-Agent", "matrix/1")
224
+ if a.session != nil {
225
+ req.AddCookie(a.session)
226
+ }
227
+ if r.Method != http.MethodGet && r.CSRF != "none" {
228
+ tok, ck := e.csrf(t, a.session)
229
+ req.Header.Set(auth.CSRFHeader, tok)
230
+ req.AddCookie(ck)
231
+ }
232
+ return req
233
+ }
234
+
235
+ func multipartBody(t *testing.T, m *Multipart, a actor) (*bytes.Buffer, string) {
236
+ t.Helper()
237
+ sub := strings.NewReplacer("${actorEmpNo}", a.empNo, "${actorName}", a.name)
238
+ var buf bytes.Buffer
239
+ mw := multipart.NewWriter(&buf)
240
+ for k, v := range m.Fields {
241
+ if err := mw.WriteField(k, sub.Replace(v)); err != nil {
242
+ t.Fatal(err)
243
+ }
244
+ }
245
+ w, err := mw.CreateFormFile(m.File.Field, sub.Replace(m.File.Filename))
246
+ if err != nil {
247
+ t.Fatal(err)
248
+ }
249
+ switch m.File.Content {
250
+ case "pdf":
251
+ _, _ = w.Write([]byte("%PDF-1.7\nmatrix\n%%EOF\n"))
252
+ default:
253
+ t.Fatalf("unknown file content %q", m.File.Content)
254
+ }
255
+ if err := mw.Close(); err != nil {
256
+ t.Fatal(err)
257
+ }
258
+ return &buf, mw.FormDataContentType()
259
+ }
260
+
261
+ func TestPermissionMatrix(t *testing.T) {
262
+ e := newEnv(t)
263
+ for _, r := range load(t) {
264
+ if r.State != "" {
265
+ e.setState(t, r.State)
266
+ }
267
+ for _, name := range Actors {
268
+ want, ok := r.Expect[name]
269
+ if !ok {
270
+ continue
271
+ }
272
+ t.Run(r.ID+"/"+name, func(t *testing.T) {
273
+ a := e.newActor(t, name)
274
+ h := e.checked
275
+ if r.OutsideSpec {
276
+ h = e.plain
277
+ }
278
+ rec := e.do(h, e.request(t, r, a))
279
+ if rec.Code != want.Status {
280
+ t.Fatalf("%s %s as %s: status %d, want %d (%s)", r.Method, r.Path, name, rec.Code, want.Status, rec.Body)
281
+ }
282
+ if want.Code != "" {
283
+ var p struct{ Code string }
284
+ if err := json.Unmarshal(rec.Body.Bytes(), &p); err != nil || p.Code != want.Code {
285
+ t.Fatalf("%s %s as %s: code %q, want %q (%s)", r.Method, r.Path, name, p.Code, want.Code, rec.Body)
286
+ }
287
+ }
288
+ })
289
+ }
290
+ }
291
+ }
@@ -0,0 +1,46 @@
1
+ //go:build integration
2
+
3
+ package migration_test
4
+
5
+ import (
6
+ "strings"
7
+ "testing"
8
+
9
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/shared/dbtest"
10
+ )
11
+
12
+ func TestPendingFileDeletionRules(t *testing.T) {
13
+ d := dbtest.New(t)
14
+ ins := func(key, reason string) error {
15
+ _, err := d.Exec(`INSERT INTO pending_file_deletion (object_key, reason) VALUES (?, ?)`, key, reason)
16
+ return err
17
+ }
18
+ for _, r := range []string{"REPLACED", "DELETED", "UPLOAD_ABORTED"} {
19
+ if err := ins("slides/a/b.pdf", r); err != nil {
20
+ t.Errorf("reason %s (same key again): want accepted, got %v", r, err)
21
+ }
22
+ }
23
+ if err := ins(strings.Repeat("k", 128), "DELETED"); err != nil {
24
+ t.Errorf("128-char key: %v", err)
25
+ }
26
+ for name, c := range map[string][2]string{
27
+ "unknown reason": {"slides/x.pdf", "EXPIRED"},
28
+ "lower-case reason": {"slides/x.pdf", "replaced"},
29
+ "empty key": {"", "DELETED"},
30
+ "129-char key": {strings.Repeat("k", 129), "DELETED"},
31
+ } {
32
+ if err := ins(c[0], c[1]); err == nil {
33
+ t.Errorf("%s: want rejected", name)
34
+ }
35
+ }
36
+
37
+ var attempts int
38
+ var due bool
39
+ if err := d.QueryRow(`SELECT attempts, next_attempt_at <= NOW(6) AND created_at <= NOW(6)
40
+ FROM pending_file_deletion WHERE reason = 'DELETED' LIMIT 1`).Scan(&attempts, &due); err != nil {
41
+ t.Fatal(err)
42
+ }
43
+ if attempts != 0 || !due {
44
+ t.Errorf("defaults: attempts=%d due=%v, want 0 and due now", attempts, due)
45
+ }
46
+ }
@@ -40,6 +40,6 @@ Every write (`POST`, `PUT`, `DELETE`, including login, register and logout) MUST
40
40
  Notes:
41
41
 
42
42
  - `DELETE /ideas/{id}/members/{empNo}` works on both `PENDING` and `ACCEPTED` rows (reject or remove); `DELETE …/members/me` works on the caller's own row (withdraw or leave). `me` is never a 6-digit employee ID, so the two paths cannot collide (PRD §6.7).
43
- - List endpoints enforce a maximum page size of 100 (`GET /ideas`: `page`, `size`, filters, `sort`, `order`; PRD §6.6). `GET /admin/audit-logs` uses keyset pagination with `cursor` and `limit` (PRD §6.11).
43
+ - List endpoints enforce a maximum page size of 100 (`GET /ideas`: `page`, `size`, filters, `q`, `sort`, `order`; PRD §6.6). `GET /admin/audit-logs` uses keyset pagination with `cursor` and `limit` (PRD §6.11).
44
44
  - Unknown paths: 401 without a session, 404 with one; wrong method 405; unsupported `Accept` 406 (PRD §6.1, OI-15).
45
45
  - Operations endpoints are on the management port and are not routed by the public ingress (PRD §8.3).
@@ -37,7 +37,7 @@ Format: RFC 9457 `application/problem+json`.
37
37
 
38
38
  - `code` is the stable contract; `detail` is English and not for display (the UI localizes by `code`).
39
39
  - With several field errors, `errors[]` lists all of them in form-field order; the top-level `code` equals `errors[0].code` when that is a registered specific code, otherwise `VALIDATION_FAILED`.
40
- - Generic field codes: `REQUIRED`, `INVALID_FORMAT`, `OUT_OF_RANGE`, `INVALID_ENUM`. Per-field rules (which error a field reports first) are in PRD §6.2. The password is the exception: `errors[]` lists every violated rule (PRD §6.4).
40
+ - Generic field codes: `REQUIRED`, `INVALID_FORMAT`, `OUT_OF_RANGE`, `INVALID_ENUM`. Per-field rules (which error a field reports first) are in PRD §6.2. The password is the exception: `errors[]` lists every violated rule (PRD §6.4), each as `{ "field": "password", "code": "PASSWORD_POLICY", "rule": "<rule id>" }` in table order. `rule` appears only on these entries.
41
41
  - `requestId` is a server-generated UUID. An incoming `X-Request-Id` is reused only if it is a valid UUID. It is returned in the response header and the error body.
42
42
  - 500 responses carry only `INTERNAL_ERROR` and `requestId`: no stack traces, SQL or paths.
43
43
  - A malformed ID in a path is a 404, not a 400.
@@ -1,5 +1,5 @@
1
1
  import { QueryClient } from '@tanstack/react-query';
2
- import { render, screen, waitFor, within } from '@testing-library/react';
2
+ import { render, screen, waitFor } from '@testing-library/react';
3
3
  import { http, HttpResponse } from 'msw';
4
4
  import { createMemoryRouter } from 'react-router';
5
5
  import { axe } from 'vitest-axe';
@@ -9,7 +9,7 @@ import { initI18n } from '@/shared/i18n';
9
9
  import { App } from './App';
10
10
  import { safeNext } from './access';
11
11
  import { PAGES, routes } from './router';
12
- import { ROUTES, navFor, type Role } from './routes';
12
+ import { ROUTES, type Role } from './routes';
13
13
 
14
14
  const API = 'http://localhost:3000/api/v1';
15
15
  const me = (role: Role | null) =>
@@ -32,31 +32,6 @@ async function open(path: string, role: Role | null, state: 'NOT_OPEN' | 'OPEN'
32
32
  /** A concrete URL for a route pattern. */
33
33
  const concrete = (p: string) => p.replace(':id', '11111111-1111-4111-8111-111111111111');
34
34
 
35
- describe('deadline banner (3 states)', () => {
36
- it.each([
37
- ['NOT_OPEN', 'Submissions are not open yet.'],
38
- ['OPEN', 'Submissions are open.'],
39
- ['CLOSED', 'Submissions are closed.'],
40
- ] as const)('%s', async (state, text) => {
41
- await open('/login', null, state);
42
- const region = await screen.findByRole('region', { name: 'Submission status' });
43
- expect(await within(region).findByText(new RegExp(text))).toBeInTheDocument();
44
- expect(region).toHaveAttribute('data-state', state);
45
- });
46
-
47
- it('shows an error state when the deadline cannot be loaded', async () => {
48
- server.use(me(null), http.get(`${API}/deadline`, () => problem(503, 'SERVICE_UNAVAILABLE')));
49
- await initI18n('en');
50
- render(<App router={createMemoryRouter(routes, { initialEntries: ['/login'] })} client={new QueryClient()} />);
51
- expect(await screen.findByText('The submission status could not be loaded.')).toBeInTheDocument();
52
- });
53
-
54
- it('is present in AppShell too', async () => {
55
- await open('/ideas', 'USER', 'CLOSED');
56
- expect(await screen.findByRole('region', { name: 'Submission status' })).toBeInTheDocument();
57
- });
58
- });
59
-
60
35
  describe('route access (Frontend.md §6.3)', () => {
61
36
  const nonPublic = ROUTES.filter((r) => r.access !== 'public');
62
37
 
@@ -98,19 +73,6 @@ describe('route access (Frontend.md §6.3)', () => {
98
73
  });
99
74
  });
100
75
 
101
- describe('navigation matches the route table', () => {
102
- it.each([
103
- ['USER', ['Ideas', 'Join requests']],
104
- ['ADMIN', ['Ideas', 'Deadline', 'Statistics']],
105
- ] as const)('%s', async (role, labels) => {
106
- await open('/ideas', role);
107
- const nav = await screen.findByRole('navigation', { name: 'Main navigation' });
108
- expect(within(nav).getAllByRole('link').map((a) => a.textContent)).toEqual(labels);
109
- expect(within(nav).getAllByRole('link').map((a) => a.getAttribute('href'))).toEqual(navFor(role).map((r) => r.path));
110
- expect(!!screen.queryByText('Administrator')).toBe(role === 'ADMIN');
111
- });
112
- });
113
-
114
76
  describe('safeNext (§6.4)', () => {
115
77
  it.each([
116
78
  ['/ideas?page=2', 'USER', '/ideas?page=2'],
@@ -137,7 +99,6 @@ describe('every route has a page', () => {
137
99
  async (path, role) => {
138
100
  const { container } = await open(concrete(path), role);
139
101
  expect(await screen.findAllByRole('heading', { level: 1 })).toHaveLength(1);
140
- await screen.findByRole('region', { name: 'Submission status' });
141
102
  expect((await axe(container)).violations).toEqual([]);
142
103
  },
143
104
  );
@@ -157,7 +118,6 @@ describe('accessibility', () => {
157
118
  ] as const)('%s as %s has no axe violations', async (path, role) => {
158
119
  const { container } = await open(path, role);
159
120
  await screen.findByRole('heading', { level: 1 });
160
- await screen.findByRole('region', { name: 'Submission status' });
161
121
  expect((await axe(container)).violations).toEqual([]);
162
122
  });
163
123
  });
@@ -1,18 +1,27 @@
1
- import { useEffect } from 'react';
1
+ import { useCallback, useEffect } from 'react';
2
2
  import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
3
3
  import { RouterProvider, createBrowserRouter, type DataRouter } from 'react-router';
4
4
  import { setUnauthenticatedHandler } from '@/shared/api';
5
5
  import { routes } from './router';
6
- import { handleSessionEnded } from './session';
6
+ import { IdleWarning } from '@/features/auth/IdleWarning';
7
+ import { handleSessionEnded, useMe } from './session';
7
8
 
8
9
  const queryClient = new QueryClient();
9
10
  const browserRouter = createBrowserRouter(routes);
10
11
 
12
+ /** Mounted inside the providers so it can read `me`; ends the session like a 401 when the idle time is up. */
13
+ function Idle({ router, client }: { router: DataRouter; client: QueryClient }) {
14
+ const { data: me } = useMe();
15
+ const onExpire = useCallback(() => handleSessionEnded(client, router), [client, router]);
16
+ return <IdleWarning signedIn={!!me} onExpire={onExpire} />;
17
+ }
18
+
11
19
  export function App({ router = browserRouter, client = queryClient }: { router?: DataRouter; client?: QueryClient }) {
12
20
  useEffect(() => setUnauthenticatedHandler(() => handleSessionEnded(client, router)), [client, router]);
13
21
  return (
14
22
  <QueryClientProvider client={client}>
15
23
  <RouterProvider router={router} />
24
+ <Idle router={router} client={client} />
16
25
  </QueryClientProvider>
17
26
  );
18
27
  }
@@ -12,18 +12,18 @@
12
12
  --card-foreground: oklch(0.2046 0 0);
13
13
  --popover: oklch(0.9911 0 0);
14
14
  --popover-foreground: oklch(0.4386 0 0);
15
- --primary: oklch(0.8348 0.1302 160.9080);
16
- --primary-foreground: oklch(0.2626 0.0147 166.4589);
15
+ --primary: oklch(0.5203 0.1364 144.2);
16
+ --primary-foreground: oklch(0.9911 0 0);
17
17
  --secondary: oklch(0.9940 0 0);
18
18
  --secondary-foreground: oklch(0.2046 0 0);
19
19
  --muted: oklch(0.9461 0 0);
20
- --muted-foreground: oklch(0.2435 0 0);
20
+ --muted-foreground: oklch(0.4386 0 0);
21
21
  --accent: oklch(0.9461 0 0);
22
22
  --accent-foreground: oklch(0.2435 0 0);
23
23
  --destructive: oklch(0.5523 0.1927 32.7272);
24
24
  --border: oklch(0.9037 0 0);
25
25
  --input: oklch(0.9731 0 0);
26
- --ring: oklch(0.8348 0.1302 160.9080);
26
+ --ring: oklch(0.6 0.15 144.2);
27
27
  --destructive-foreground: oklch(0.9934 0.0032 17.2118);
28
28
  --chart-1: oklch(0.8348 0.1302 160.9080);
29
29
  --chart-2: oklch(0.6231 0.1880 259.8145);