@johnnyboy11234/esg-idea-contest 0.2.4 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/backend/api/openapi.yaml +83 -0
- package/backend/cmd/server/main.go +35 -2
- package/backend/db/migration/V005__idea.sql +49 -0
- package/backend/db/migration/V006__login_lock.sql +15 -0
- package/backend/db/migration/V007__login_attempt.sql +15 -0
- package/backend/db/migration/V008__pending_file_deletion.sql +27 -0
- package/backend/db/migration/V009__login_throttle_audit.sql +13 -0
- package/backend/go.mod +17 -3
- package/backend/go.sum +48 -7
- package/backend/internal/account/account_test.go +6 -6
- package/backend/internal/account/integration_test.go +45 -9
- package/backend/internal/account/keyboard.go +75 -0
- package/backend/internal/account/keyboard_test.go +43 -0
- package/backend/internal/account/logging_integration_test.go +1 -1
- package/backend/internal/account/password.go +125 -0
- package/backend/internal/account/validate.go +4 -21
- package/backend/internal/api/api.gen.go +224 -0
- package/backend/internal/auth/internal/store/login_attempt.go +53 -0
- package/backend/internal/auth/internal/store/login_lock.go +77 -0
- package/backend/internal/auth/internal/store/login_throttle_audit.go +42 -0
- package/backend/internal/auth/internal/store/session.go +15 -7
- package/backend/internal/auth/login.go +16 -4
- package/backend/internal/auth/login_integration_test.go +14 -1
- package/backend/internal/auth/login_ip_integration_test.go +153 -0
- package/backend/internal/auth/login_lock_integration_test.go +205 -0
- package/backend/internal/auth/login_test.go +15 -0
- package/backend/internal/auth/login_throttle_audit_integration_test.go +88 -0
- package/backend/internal/auth/purge.go +93 -0
- package/backend/internal/auth/purge_integration_test.go +220 -0
- package/backend/internal/auth/purge_test.go +51 -0
- package/backend/internal/auth/session.go +0 -6
- package/backend/internal/auth/session_integration_test.go +3 -3
- package/backend/internal/auth/throttle.go +122 -0
- package/backend/internal/deadline/gate.go +80 -0
- package/backend/internal/deadline/gate_integration_test.go +67 -0
- package/backend/internal/deadline/gate_test.go +61 -0
- package/backend/internal/idea/create.go +352 -0
- package/backend/internal/idea/create_integration_test.go +650 -0
- package/backend/internal/idea/handler.go +148 -0
- package/backend/internal/idea/handler_integration_test.go +107 -0
- package/backend/internal/idea/integration_test.go +149 -0
- package/backend/internal/idea/internal/store/list.go +146 -0
- package/backend/internal/idea/internal/store/list_test.go +33 -0
- package/backend/internal/idea/internal/store/store.go +166 -0
- package/backend/internal/idea/list.go +166 -0
- package/backend/internal/idea/list_integration_test.go +252 -0
- package/backend/internal/idea/list_test.go +96 -0
- package/backend/internal/idea/service.go +53 -0
- package/backend/internal/server/api.go +17 -0
- package/backend/internal/server/public.go +1 -0
- package/backend/internal/server/slides.go +21 -0
- package/backend/internal/shared/config/config.go +44 -1
- package/backend/internal/shared/config/config_test.go +21 -13
- package/backend/internal/shared/db/intx_integration_test.go +179 -0
- package/backend/internal/shared/dbtest/dbtest.go +21 -3
- package/backend/internal/shared/dbtest/faults.go +162 -0
- package/backend/internal/shared/job/job.go +54 -0
- package/backend/internal/shared/job/job_test.go +105 -0
- package/backend/internal/shared/web/multipart.go +175 -0
- package/backend/internal/shared/web/multipart_test.go +169 -0
- package/backend/internal/shared/web/problem.go +1 -0
- package/backend/internal/shared/web/request.go +42 -1
- package/backend/internal/shared/web/web_test.go +36 -0
- package/backend/internal/storage/expectedfilename.go +43 -0
- package/backend/internal/storage/expectedfilename_test.go +16 -0
- package/backend/internal/storage/internal/store/pending.go +41 -0
- package/backend/internal/storage/minio.go +139 -0
- package/backend/internal/storage/minio_integration_test.go +87 -0
- package/backend/internal/storage/minio_test.go +84 -0
- package/backend/internal/storage/pending.go +144 -0
- package/backend/internal/storage/pending_integration_test.go +324 -0
- package/backend/internal/storage/pending_test.go +23 -0
- package/backend/internal/storage/validator.go +100 -0
- package/backend/internal/storage/validator_test.go +95 -0
- package/backend/test/contract/expected_filename_test.go +60 -0
- package/backend/test/contract/password_test.go +46 -0
- package/backend/test/matrix/file_test.go +52 -0
- package/backend/test/matrix/load_test.go +62 -0
- package/backend/test/matrix/matrix_integration_test.go +291 -0
- package/backend/test/migration/pending_file_deletion_test.go +46 -0
- package/docs/API/api-endpoints.md +1 -1
- package/docs/API/error-codes.md +1 -1
- package/frontend/public/auth-hero-login.jpg +0 -0
- package/frontend/public/auth-hero-register.jpg +0 -0
- package/frontend/src/app/App.test.tsx +2 -42
- package/frontend/src/app/App.tsx +11 -2
- package/frontend/src/app/index.css +4 -4
- package/frontend/src/app/layouts.tsx +7 -135
- package/frontend/src/app/pages/JoinRequests.tsx +2 -2
- package/frontend/src/app/pages/NotFound.tsx +1 -1
- package/frontend/src/app/pages/admin.tsx +1 -1
- package/frontend/src/app/pages/auth.tsx +53 -25
- package/frontend/src/app/pages/ideas.tsx +7 -7
- package/frontend/src/app/pages/login.test.tsx +3 -54
- package/frontend/src/app/pages/register.test.tsx +1 -1
- package/frontend/src/app/router.tsx +2 -6
- package/frontend/src/features/auth/IdleWarning.test.tsx +82 -0
- package/frontend/src/features/auth/IdleWarning.tsx +66 -0
- package/frontend/src/features/auth/LoginForm.tsx +8 -1
- package/frontend/src/shared/api/activity.ts +20 -0
- package/frontend/src/shared/api/http.ts +2 -0
- package/frontend/src/shared/api/index.ts +1 -0
- package/frontend/src/shared/i18n/locales/en.ts +1 -0
- package/frontend/src/shared/i18n/locales/zh-TW.ts +1 -0
- package/package.json +1 -1
- package/testdata/expected-filename.json +93 -0
- package/testdata/password.json +425 -0
- package/testdata/permission-matrix.json +157 -0
- package/testdata/schema/expected-filename.schema.json +32 -0
- package/testdata/schema/password.schema.json +59 -0
- package/testdata/schema/permission-matrix.schema.json +73 -0
|
@@ -0,0 +1,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).
|
package/docs/API/error-codes.md
CHANGED
|
@@ -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.
|
|
Binary file
|
|
Binary file
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { QueryClient } from '@tanstack/react-query';
|
|
2
|
-
import { render, screen, waitFor
|
|
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,
|
|
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
|
});
|
package/frontend/src/app/App.tsx
CHANGED
|
@@ -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 {
|
|
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.
|
|
16
|
-
--primary-foreground: oklch(0.
|
|
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.
|
|
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.
|
|
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);
|