@johnnyboy11234/esg-idea-contest 0.2.0 → 0.2.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 +44 -0
- package/backend/cmd/server/main.go +11 -1
- package/backend/db/migration/V004__session.sql +19 -0
- package/backend/go.mod +1 -1
- package/backend/internal/account/account_test.go +191 -0
- package/backend/internal/account/handler.go +58 -0
- package/backend/internal/account/integration_test.go +268 -0
- package/backend/internal/account/internal/store/store.go +35 -0
- package/backend/internal/account/service.go +153 -0
- package/backend/internal/account/validate.go +100 -0
- package/backend/internal/api/api.gen.go +82 -0
- package/backend/internal/audit/integration_test.go +1 -1
- package/backend/internal/auth/internal/store/session.go +111 -0
- package/backend/internal/auth/session.go +113 -0
- package/backend/internal/auth/session_integration_test.go +245 -0
- package/backend/internal/auth/session_test.go +36 -0
- package/backend/internal/server/api.go +7 -0
- package/backend/internal/server/errors.go +10 -25
- package/backend/internal/server/errors_test.go +18 -0
- package/backend/internal/shared/db/intx_test.go +25 -0
- package/backend/internal/shared/db/mysqlerr.go +26 -0
- package/backend/internal/shared/security/security.go +6 -2
- package/backend/internal/shared/security/security_test.go +6 -4
- package/backend/internal/shared/web/problem.go +6 -0
- package/backend/internal/shared/web/request.go +27 -0
- package/frontend/src/shared/text/index.ts +93 -0
- package/frontend/src/shared/text/text.test.ts +22 -0
- package/package.json +1 -1
- package/frontend/src/shared/text/.gitkeep +0 -0
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
//go:build integration
|
|
2
|
+
|
|
3
|
+
package auth_test
|
|
4
|
+
|
|
5
|
+
import (
|
|
6
|
+
"context"
|
|
7
|
+
"crypto/sha256"
|
|
8
|
+
"database/sql"
|
|
9
|
+
"encoding/base64"
|
|
10
|
+
"testing"
|
|
11
|
+
"time"
|
|
12
|
+
|
|
13
|
+
"github.com/google/uuid"
|
|
14
|
+
|
|
15
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/auth"
|
|
16
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/shared/dbtest"
|
|
17
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/shared/security"
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
const bcryptHash = "$2a$10$abcdefghijklmnopqrstuuABCDEFGHIJKLMNOPQRSTUVWXYZ01234" // shape only; satisfies ck_app_user_hash
|
|
21
|
+
|
|
22
|
+
func addUser(t *testing.T, db *sql.DB, empNo, role string, enabled bool) uuid.UUID {
|
|
23
|
+
t.Helper()
|
|
24
|
+
id := uuid.New()
|
|
25
|
+
var email any = empNo + "@example.com"
|
|
26
|
+
if role == "ADMIN" {
|
|
27
|
+
email = nil
|
|
28
|
+
}
|
|
29
|
+
if _, err := db.Exec(`INSERT INTO app_user (id, emp_no, name, email, password_hash, role, enabled) VALUES (?,?,?,?,?,?,?)`,
|
|
30
|
+
id.String(), empNo, "N "+empNo, email, bcryptHash, role, enabled); err != nil {
|
|
31
|
+
t.Fatal(err)
|
|
32
|
+
}
|
|
33
|
+
return id
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// age moves a session's timestamps into the past (DB time is the only clock,
|
|
37
|
+
// so tests change the rows rather than the clock).
|
|
38
|
+
func age(t *testing.T, db *sql.DB, id string, created, lastAccess time.Duration) {
|
|
39
|
+
t.Helper()
|
|
40
|
+
h, _ := auth.Binding(id)
|
|
41
|
+
if _, err := db.Exec(`UPDATE session SET created_at = NOW(6) - INTERVAL ? SECOND, last_access_at = NOW(6) - INTERVAL ? SECOND WHERE id_hash = ?`,
|
|
42
|
+
int(created.Seconds()), int(lastAccess.Seconds()), h); err != nil {
|
|
43
|
+
t.Fatal(err)
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
func lastAccess(t *testing.T, db *sql.DB, id string) time.Time {
|
|
48
|
+
t.Helper()
|
|
49
|
+
h, _ := auth.Binding(id)
|
|
50
|
+
var ts time.Time
|
|
51
|
+
if err := db.QueryRow(`SELECT last_access_at FROM session WHERE id_hash = ?`, h).Scan(&ts); err != nil {
|
|
52
|
+
t.Fatal(err)
|
|
53
|
+
}
|
|
54
|
+
return ts
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
func TestSessions(t *testing.T) {
|
|
58
|
+
db := dbtest.New(t)
|
|
59
|
+
ctx := context.Background()
|
|
60
|
+
s := auth.New(db)
|
|
61
|
+
user := addUser(t, db, "100001", "USER", true)
|
|
62
|
+
|
|
63
|
+
create := func(t *testing.T, u uuid.UUID) string {
|
|
64
|
+
t.Helper()
|
|
65
|
+
id, err := s.Create(ctx, u)
|
|
66
|
+
if err != nil {
|
|
67
|
+
t.Fatal(err)
|
|
68
|
+
}
|
|
69
|
+
return id
|
|
70
|
+
}
|
|
71
|
+
valid := func(t *testing.T, id string) bool {
|
|
72
|
+
t.Helper()
|
|
73
|
+
_, ok, err := s.Validate(ctx, id)
|
|
74
|
+
if err != nil {
|
|
75
|
+
t.Fatal(err)
|
|
76
|
+
}
|
|
77
|
+
return ok
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
t.Run("create stores only the SHA-256 and returns a 32-byte ID", func(t *testing.T) {
|
|
81
|
+
id := create(t, user)
|
|
82
|
+
raw, err := base64.RawURLEncoding.DecodeString(id)
|
|
83
|
+
if err != nil || len(raw) != 32 {
|
|
84
|
+
t.Fatalf("id %q: %d bytes, %v", id, len(raw), err)
|
|
85
|
+
}
|
|
86
|
+
sum := sha256.Sum256(raw)
|
|
87
|
+
var n int
|
|
88
|
+
if err := db.QueryRow(`SELECT COUNT(*) FROM session WHERE id_hash = ?`, sum[:]).Scan(&n); err != nil || n != 1 {
|
|
89
|
+
t.Fatalf("hash row count = %d, %v", n, err)
|
|
90
|
+
}
|
|
91
|
+
if err := db.QueryRow(`SELECT COUNT(*) FROM session WHERE id_hash = ?`, raw).Scan(&n); err != nil || n != 0 {
|
|
92
|
+
t.Fatalf("raw ID stored: %d, %v", n, err)
|
|
93
|
+
}
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
t.Run("every login gets a new ID", func(t *testing.T) {
|
|
97
|
+
if a, b := create(t, user), create(t, user); a == b {
|
|
98
|
+
t.Fatal("same ID twice")
|
|
99
|
+
}
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
t.Run("validate returns the principal with the role from app_user", func(t *testing.T) {
|
|
103
|
+
admin := addUser(t, db, "200001", "ADMIN", true)
|
|
104
|
+
p, ok, err := s.Validate(ctx, create(t, admin))
|
|
105
|
+
if err != nil || !ok {
|
|
106
|
+
t.Fatalf("validate: %v %v", ok, err)
|
|
107
|
+
}
|
|
108
|
+
want := security.Principal{UserID: admin, EmpNo: "200001", Role: security.RoleAdmin}
|
|
109
|
+
if p != want {
|
|
110
|
+
t.Fatalf("principal = %+v, want %+v", p, want)
|
|
111
|
+
}
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
t.Run("unknown and malformed IDs are not valid", func(t *testing.T) {
|
|
115
|
+
unknown := base64.RawURLEncoding.EncodeToString(make([]byte, 32))
|
|
116
|
+
for _, id := range []string{unknown, "", "garbage"} {
|
|
117
|
+
if valid(t, id) {
|
|
118
|
+
t.Errorf("%q accepted", id)
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
t.Run("matched rows count: a valid session that needs no refresh still validates", func(t *testing.T) {
|
|
124
|
+
id := create(t, user)
|
|
125
|
+
before := lastAccess(t, db, id)
|
|
126
|
+
for i := 0; i < 2; i++ {
|
|
127
|
+
if !valid(t, id) {
|
|
128
|
+
t.Fatalf("validate #%d failed although the row matched", i+1)
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
if after := lastAccess(t, db, id); !after.Equal(before) {
|
|
132
|
+
t.Fatalf("last_access_at moved inside the 60 s window: %v -> %v", before, after)
|
|
133
|
+
}
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
t.Run("refresh happens only when last access is older than 60 s", func(t *testing.T) {
|
|
137
|
+
id := create(t, user)
|
|
138
|
+
age(t, db, id, 0, 59*time.Second)
|
|
139
|
+
before := lastAccess(t, db, id)
|
|
140
|
+
if !valid(t, id) || !lastAccess(t, db, id).Equal(before) {
|
|
141
|
+
t.Fatal("59 s old: must validate without writing")
|
|
142
|
+
}
|
|
143
|
+
age(t, db, id, 0, 61*time.Second)
|
|
144
|
+
before = lastAccess(t, db, id)
|
|
145
|
+
if !valid(t, id) || !lastAccess(t, db, id).After(before) {
|
|
146
|
+
t.Fatal("61 s old: must validate and refresh")
|
|
147
|
+
}
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
t.Run("idle timeout is 30 minutes", func(t *testing.T) {
|
|
151
|
+
id := create(t, user)
|
|
152
|
+
age(t, db, id, 0, 29*time.Minute+50*time.Second)
|
|
153
|
+
if !valid(t, id) {
|
|
154
|
+
t.Fatal("29m50s idle must still be valid")
|
|
155
|
+
}
|
|
156
|
+
age(t, db, id, 0, 30*time.Minute+5*time.Second)
|
|
157
|
+
if valid(t, id) {
|
|
158
|
+
t.Fatal("30m05s idle must be expired")
|
|
159
|
+
}
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
t.Run("absolute timeout is 8 hours, even for an active session", func(t *testing.T) {
|
|
163
|
+
id := create(t, user)
|
|
164
|
+
age(t, db, id, 7*time.Hour+59*time.Minute, 0)
|
|
165
|
+
if !valid(t, id) {
|
|
166
|
+
t.Fatal("7h59m old must still be valid")
|
|
167
|
+
}
|
|
168
|
+
age(t, db, id, 8*time.Hour+time.Minute, 0)
|
|
169
|
+
if valid(t, id) {
|
|
170
|
+
t.Fatal("8h01m old must be expired although just used")
|
|
171
|
+
}
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
t.Run("disabled user is rejected on the next request", func(t *testing.T) {
|
|
175
|
+
u := addUser(t, db, "100002", "USER", true)
|
|
176
|
+
id := create(t, u)
|
|
177
|
+
if !valid(t, id) {
|
|
178
|
+
t.Fatal("enabled user must validate")
|
|
179
|
+
}
|
|
180
|
+
if _, err := db.Exec(`UPDATE app_user SET enabled = FALSE WHERE id = ?`, u.String()); err != nil {
|
|
181
|
+
t.Fatal(err)
|
|
182
|
+
}
|
|
183
|
+
if valid(t, id) {
|
|
184
|
+
t.Fatal("disabled user must not validate")
|
|
185
|
+
}
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
t.Run("revoke is idempotent and ends only that session", func(t *testing.T) {
|
|
189
|
+
a, b := create(t, user), create(t, user)
|
|
190
|
+
for i := 0; i < 2; i++ {
|
|
191
|
+
if err := s.Revoke(ctx, a); err != nil {
|
|
192
|
+
t.Fatal(err)
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
if err := s.Revoke(ctx, "garbage"); err != nil {
|
|
196
|
+
t.Fatal(err)
|
|
197
|
+
}
|
|
198
|
+
if valid(t, a) || !valid(t, b) {
|
|
199
|
+
t.Fatal("revoke must end a and leave b")
|
|
200
|
+
}
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
t.Run("revoke all ends every session of the user only", func(t *testing.T) {
|
|
204
|
+
u, other := addUser(t, db, "100003", "USER", true), addUser(t, db, "100004", "USER", true)
|
|
205
|
+
a, b, o := create(t, u), create(t, u), create(t, other)
|
|
206
|
+
if err := s.RevokeAll(ctx, u); err != nil {
|
|
207
|
+
t.Fatal(err)
|
|
208
|
+
}
|
|
209
|
+
if valid(t, a) || valid(t, b) || !valid(t, o) {
|
|
210
|
+
t.Fatal("RevokeAll must end the user's sessions and no others")
|
|
211
|
+
}
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
t.Run("purge removes expired sessions only", func(t *testing.T) {
|
|
215
|
+
u := addUser(t, db, "100005", "USER", true)
|
|
216
|
+
live, idle, old := create(t, u), create(t, u), create(t, u)
|
|
217
|
+
age(t, db, idle, time.Hour, time.Hour)
|
|
218
|
+
age(t, db, old, 9*time.Hour, 0)
|
|
219
|
+
n, err := s.Purge(ctx)
|
|
220
|
+
if err != nil || n < 2 {
|
|
221
|
+
t.Fatalf("purged %d, %v; want at least 2", n, err)
|
|
222
|
+
}
|
|
223
|
+
if !valid(t, live) {
|
|
224
|
+
t.Fatal("live session purged")
|
|
225
|
+
}
|
|
226
|
+
for _, id := range []string{idle, old} {
|
|
227
|
+
h, _ := auth.Binding(id)
|
|
228
|
+
var c int
|
|
229
|
+
if err := db.QueryRow(`SELECT COUNT(*) FROM session WHERE id_hash = ?`, h).Scan(&c); err != nil || c != 0 {
|
|
230
|
+
t.Fatalf("expired row still present: %d, %v", c, err)
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
t.Run("deleting a user deletes the sessions", func(t *testing.T) {
|
|
236
|
+
u := addUser(t, db, "100006", "USER", true)
|
|
237
|
+
id := create(t, u)
|
|
238
|
+
if _, err := db.Exec(`DELETE FROM app_user WHERE id = ?`, u.String()); err != nil {
|
|
239
|
+
t.Fatal(err)
|
|
240
|
+
}
|
|
241
|
+
if valid(t, id) {
|
|
242
|
+
t.Fatal("session survived its user")
|
|
243
|
+
}
|
|
244
|
+
})
|
|
245
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
package auth_test
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"crypto/sha256"
|
|
5
|
+
"encoding/base64"
|
|
6
|
+
"testing"
|
|
7
|
+
|
|
8
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/auth"
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
func TestBindingIsSHA256OfDecodedID(t *testing.T) {
|
|
12
|
+
raw := make([]byte, 32)
|
|
13
|
+
for i := range raw {
|
|
14
|
+
raw[i] = byte(i)
|
|
15
|
+
}
|
|
16
|
+
want := sha256.Sum256(raw)
|
|
17
|
+
got, ok := auth.Binding(base64.RawURLEncoding.EncodeToString(raw))
|
|
18
|
+
if !ok || string(got) != string(want[:]) {
|
|
19
|
+
t.Fatalf("Binding = %x, %v; want %x", got, ok, want)
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
func TestBindingRejectsMalformedIDs(t *testing.T) {
|
|
24
|
+
for name, id := range map[string]string{
|
|
25
|
+
"empty": "",
|
|
26
|
+
"not base64": "!!!",
|
|
27
|
+
"too short": base64.RawURLEncoding.EncodeToString(make([]byte, 31)),
|
|
28
|
+
"too long": base64.RawURLEncoding.EncodeToString(make([]byte, 33)),
|
|
29
|
+
"padded": base64.URLEncoding.EncodeToString(make([]byte, 32)),
|
|
30
|
+
"std alpha": base64.StdEncoding.EncodeToString([]byte{0xfb, 0xff, 0xfe, 0xfb, 0xff, 0xfe, 0xfb, 0xff, 0xfe, 0xfb, 0xff, 0xfe, 0xfb, 0xff, 0xfe, 0xfb, 0xff, 0xfe, 0xfb, 0xff, 0xfe, 0xfb, 0xff, 0xfe, 0xfb, 0xff, 0xfe, 0xfb, 0xff, 0xfe, 0xfb, 0xff}),
|
|
31
|
+
} {
|
|
32
|
+
if _, ok := auth.Binding(id); ok {
|
|
33
|
+
t.Errorf("%s: %q accepted", name, id)
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -3,6 +3,7 @@ package server
|
|
|
3
3
|
import (
|
|
4
4
|
"net/http"
|
|
5
5
|
|
|
6
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/account"
|
|
6
7
|
"github.com/brian-huanggg/AwardRegistration/backend/internal/api"
|
|
7
8
|
"github.com/brian-huanggg/AwardRegistration/backend/internal/deadline"
|
|
8
9
|
)
|
|
@@ -11,8 +12,14 @@ import (
|
|
|
11
12
|
// module has not landed yet stay api.Unimplemented (501).
|
|
12
13
|
type API struct {
|
|
13
14
|
api.Unimplemented
|
|
15
|
+
Account account.Handler
|
|
14
16
|
Deadline deadline.Handler
|
|
15
17
|
}
|
|
16
18
|
|
|
17
19
|
// GetDeadline serves GET /deadline.
|
|
18
20
|
func (a API) GetDeadline(w http.ResponseWriter, r *http.Request) { a.Deadline.GetDeadline(w, r) }
|
|
21
|
+
|
|
22
|
+
// Register serves POST /auth/register.
|
|
23
|
+
func (a API) Register(w http.ResponseWriter, r *http.Request, _ api.RegisterParams) {
|
|
24
|
+
a.Account.Register(w, r)
|
|
25
|
+
}
|
|
@@ -5,10 +5,8 @@ import (
|
|
|
5
5
|
"encoding/json"
|
|
6
6
|
"errors"
|
|
7
7
|
"log/slog"
|
|
8
|
+
"maps"
|
|
8
9
|
"net/http"
|
|
9
|
-
"net/netip"
|
|
10
|
-
|
|
11
|
-
"github.com/go-chi/chi/v5"
|
|
12
10
|
|
|
13
11
|
"github.com/brian-huanggg/AwardRegistration/backend/internal/audit"
|
|
14
12
|
"github.com/brian-huanggg/AwardRegistration/backend/internal/shared/web"
|
|
@@ -59,37 +57,24 @@ func (a ErrorAdapter) record(r *http.Request, e *web.Error) {
|
|
|
59
57
|
if outcome == "" {
|
|
60
58
|
outcome = audit.Denied
|
|
61
59
|
}
|
|
62
|
-
|
|
60
|
+
detail := map[string]string{}
|
|
61
|
+
maps.Copy(detail, e.AuditDetail)
|
|
62
|
+
detail["reason"] = e.AuditReason // the reason always wins over a detail key
|
|
63
|
+
after, _ := json.Marshal(detail) // map of strings cannot fail
|
|
63
64
|
ev := audit.Event{
|
|
64
65
|
Action: audit.Action(e.AuditAction),
|
|
65
66
|
Outcome: outcome,
|
|
66
67
|
TargetType: audit.TargetType(e.AuditTargetType),
|
|
67
68
|
TargetID: e.AuditTargetID,
|
|
68
69
|
Method: r.Method,
|
|
69
|
-
Route:
|
|
70
|
+
Route: web.RoutePattern(r),
|
|
70
71
|
Status: e.Status,
|
|
71
72
|
ErrorCode: string(e.Code),
|
|
72
|
-
IP:
|
|
73
|
+
IP: web.ClientIP(r),
|
|
73
74
|
UserAgent: r.UserAgent(),
|
|
74
|
-
After: after,
|
|
75
|
-
}
|
|
76
|
-
a.Audit.RecordRejected(r.Context(), ev)
|
|
77
|
-
}
|
|
78
75
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
func routeTemplate(r *http.Request) string {
|
|
82
|
-
if rc := chi.RouteContext(r.Context()); rc != nil {
|
|
83
|
-
return rc.RoutePattern()
|
|
76
|
+
SubmittedEmpNo: e.AuditSubmittedEmpNo,
|
|
77
|
+
After: after,
|
|
84
78
|
}
|
|
85
|
-
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
// remoteIP is the TCP peer. Trusted-proxy handling is not decided yet.
|
|
89
|
-
func remoteIP(r *http.Request) netip.Addr {
|
|
90
|
-
ap, err := netip.ParseAddrPort(r.RemoteAddr)
|
|
91
|
-
if err != nil {
|
|
92
|
-
return netip.Addr{}
|
|
93
|
-
}
|
|
94
|
-
return ap.Addr().Unmap()
|
|
79
|
+
a.Audit.RecordRejected(r.Context(), ev)
|
|
95
80
|
}
|
|
@@ -96,3 +96,21 @@ func TestAdapterReasonWithoutActionStillAnswers(t *testing.T) {
|
|
|
96
96
|
}
|
|
97
97
|
|
|
98
98
|
func contains(s, sub string) bool { return strings.Contains(s, sub) }
|
|
99
|
+
|
|
100
|
+
func TestAdapterCarriesSubmittedEmpNoAndDetail(t *testing.T) {
|
|
101
|
+
e := web.NewError(web.CodeEmpNoTaken, "taken")
|
|
102
|
+
e.AuditReason, e.AuditAction, e.AuditOutcome = "emp_no_taken", "AUTH_REGISTER", "FAILED"
|
|
103
|
+
e.AuditSubmittedEmpNo = "089290"
|
|
104
|
+
e.AuditDetail = map[string]string{"empNo": "089290", "reason": "must not override"}
|
|
105
|
+
f := &fakeRejecter{}
|
|
106
|
+
|
|
107
|
+
rec := serve(t, server.ErrorAdapter{Audit: f}, e)
|
|
108
|
+
|
|
109
|
+
if rec.Code != 409 || len(f.got) != 1 {
|
|
110
|
+
t.Fatalf("status %d rows %d", rec.Code, len(f.got))
|
|
111
|
+
}
|
|
112
|
+
g := f.got[0]
|
|
113
|
+
if g.Outcome != audit.Failed || g.SubmittedEmpNo != "089290" || string(g.After) != `{"empNo":"089290","reason":"emp_no_taken"}` {
|
|
114
|
+
t.Fatalf("event = %+v (after %s)", g, g.After)
|
|
115
|
+
}
|
|
116
|
+
}
|
|
@@ -5,6 +5,7 @@ import (
|
|
|
5
5
|
"database/sql"
|
|
6
6
|
"database/sql/driver"
|
|
7
7
|
"errors"
|
|
8
|
+
"fmt"
|
|
8
9
|
"net/http"
|
|
9
10
|
"testing"
|
|
10
11
|
"time"
|
|
@@ -221,3 +222,27 @@ func TestSQLStateClassNonMySQL(t *testing.T) {
|
|
|
221
222
|
t.Error("non-MySQL error must have no class and no contention")
|
|
222
223
|
}
|
|
223
224
|
}
|
|
225
|
+
|
|
226
|
+
func TestDuplicateKey(t *testing.T) {
|
|
227
|
+
dup := func(msg string) error { return fmt.Errorf("wrap: %w", &mysql.MySQLError{Number: 1062, Message: msg}) }
|
|
228
|
+
cases := []struct {
|
|
229
|
+
name string
|
|
230
|
+
err error
|
|
231
|
+
key string
|
|
232
|
+
ok bool
|
|
233
|
+
}{
|
|
234
|
+
{"plain", dup("Duplicate entry 'a@b' for key 'uq_app_user_email'"), "uq_app_user_email", true},
|
|
235
|
+
{"table qualified", dup("Duplicate entry '1' for key 'app_user.uq_app_user_emp_no'"), "uq_app_user_emp_no", true},
|
|
236
|
+
{"value contains the marker", dup("Duplicate entry 'for key ''x' for key 'uq_a'"), "uq_a", true},
|
|
237
|
+
{"no key in message", dup("boom"), "", true},
|
|
238
|
+
{"unterminated", dup("Duplicate entry 'x' for key 'uq_a"), "uq_a", true},
|
|
239
|
+
{"other number", &mysql.MySQLError{Number: 1205}, "", false},
|
|
240
|
+
{"not a mysql error", errors.New("x"), "", false},
|
|
241
|
+
}
|
|
242
|
+
for _, tc := range cases {
|
|
243
|
+
key, ok := DuplicateKey(tc.err)
|
|
244
|
+
if key != tc.key || ok != tc.ok {
|
|
245
|
+
t.Errorf("%s: got (%q, %v), want (%q, %v)", tc.name, key, ok, tc.key, tc.ok)
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
@@ -2,6 +2,7 @@ package db
|
|
|
2
2
|
|
|
3
3
|
import (
|
|
4
4
|
"errors"
|
|
5
|
+
"strings"
|
|
5
6
|
|
|
6
7
|
"github.com/go-sql-driver/mysql"
|
|
7
8
|
)
|
|
@@ -31,3 +32,28 @@ func sqlStateClass(err error) string {
|
|
|
31
32
|
}
|
|
32
33
|
return string(me.SQLState[:2])
|
|
33
34
|
}
|
|
35
|
+
|
|
36
|
+
const errDupEntry = 1062 // ER_DUP_ENTRY
|
|
37
|
+
|
|
38
|
+
// DuplicateKey reports whether err is a unique-key violation (1062) and names
|
|
39
|
+
// the violated key, as in "Duplicate entry 'x' for key 'uq_app_user_email'".
|
|
40
|
+
func DuplicateKey(err error) (key string, ok bool) {
|
|
41
|
+
var me *mysql.MySQLError
|
|
42
|
+
if !errors.As(err, &me) || me.Number != errDupEntry {
|
|
43
|
+
return "", false
|
|
44
|
+
}
|
|
45
|
+
const marker = "for key '"
|
|
46
|
+
i := strings.LastIndex(me.Message, marker)
|
|
47
|
+
if i < 0 {
|
|
48
|
+
return "", true
|
|
49
|
+
}
|
|
50
|
+
rest := me.Message[i+len(marker):]
|
|
51
|
+
if j := strings.IndexByte(rest, '\''); j >= 0 {
|
|
52
|
+
rest = rest[:j]
|
|
53
|
+
}
|
|
54
|
+
// MariaDB may qualify the key with the table name ("app_user.uq_x").
|
|
55
|
+
if k := strings.LastIndexByte(rest, '.'); k >= 0 {
|
|
56
|
+
rest = rest[k+1:]
|
|
57
|
+
}
|
|
58
|
+
return rest, true
|
|
59
|
+
}
|
|
@@ -4,7 +4,11 @@
|
|
|
4
4
|
// that any package may import it.
|
|
5
5
|
package security
|
|
6
6
|
|
|
7
|
-
import
|
|
7
|
+
import (
|
|
8
|
+
"context"
|
|
9
|
+
|
|
10
|
+
"github.com/google/uuid"
|
|
11
|
+
)
|
|
8
12
|
|
|
9
13
|
// Role is an account role (PRD §3). Leader, Member and Applicant are relations
|
|
10
14
|
// to one Idea, not roles.
|
|
@@ -34,7 +38,7 @@ const (
|
|
|
34
38
|
// Principal is the caller resolved from a valid session. The role is read from
|
|
35
39
|
// the account on every request, never copied into the session (PRD §6.5).
|
|
36
40
|
type Principal struct {
|
|
37
|
-
UserID
|
|
41
|
+
UserID uuid.UUID
|
|
38
42
|
EmpNo string
|
|
39
43
|
Role Role
|
|
40
44
|
}
|
|
@@ -4,6 +4,8 @@ import (
|
|
|
4
4
|
"context"
|
|
5
5
|
"testing"
|
|
6
6
|
|
|
7
|
+
"github.com/google/uuid"
|
|
8
|
+
|
|
7
9
|
"github.com/brian-huanggg/AwardRegistration/backend/internal/shared/security"
|
|
8
10
|
)
|
|
9
11
|
|
|
@@ -12,7 +14,7 @@ func TestPrincipalRoundTrip(t *testing.T) {
|
|
|
12
14
|
if _, ok := security.PrincipalFrom(ctx); ok {
|
|
13
15
|
t.Fatal("empty context must have no principal")
|
|
14
16
|
}
|
|
15
|
-
want := security.Principal{UserID:
|
|
17
|
+
want := security.Principal{UserID: uuid.New(), EmpNo: "A123", Role: security.RoleUser}
|
|
16
18
|
got, ok := security.PrincipalFrom(security.WithPrincipal(ctx, want))
|
|
17
19
|
if !ok || got != want {
|
|
18
20
|
t.Fatalf("got %+v, %v", got, ok)
|
|
@@ -21,9 +23,9 @@ func TestPrincipalRoundTrip(t *testing.T) {
|
|
|
21
23
|
|
|
22
24
|
func TestActorRole(t *testing.T) {
|
|
23
25
|
bg := context.Background()
|
|
24
|
-
user := security.Principal{UserID:
|
|
25
|
-
admin := security.Principal{UserID:
|
|
26
|
-
bad := security.Principal{UserID:
|
|
26
|
+
user := security.Principal{UserID: uuid.New(), EmpNo: "U1", Role: security.RoleUser}
|
|
27
|
+
admin := security.Principal{UserID: uuid.New(), EmpNo: "A1", Role: security.RoleAdmin}
|
|
28
|
+
bad := security.Principal{UserID: uuid.New(), EmpNo: "X", Role: "ROOT"}
|
|
27
29
|
|
|
28
30
|
tests := []struct {
|
|
29
31
|
name string
|
|
@@ -41,6 +41,12 @@ type Error struct {
|
|
|
41
41
|
// AuditTargetType and AuditTargetID name the resource, when there is one.
|
|
42
42
|
AuditTargetType string
|
|
43
43
|
AuditTargetID uuid.UUID
|
|
44
|
+
// AuditSubmittedEmpNo is the employee ID typed into a failed registration
|
|
45
|
+
// or login; the audit package sanitizes it before storing.
|
|
46
|
+
AuditSubmittedEmpNo string
|
|
47
|
+
// AuditDetail adds keys to the row's after_json beside "reason". Callers
|
|
48
|
+
// must not put secrets here.
|
|
49
|
+
AuditDetail map[string]string
|
|
44
50
|
}
|
|
45
51
|
|
|
46
52
|
func (e *Error) Error() string {
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
package web
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"net/http"
|
|
5
|
+
"net/netip"
|
|
6
|
+
|
|
7
|
+
"github.com/go-chi/chi/v5"
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
// RoutePattern is the matched chi pattern (/api/v1/ideas/{id}), never the raw
|
|
11
|
+
// path, so IDs do not leak into the audit log and rows group by route. It is
|
|
12
|
+
// empty when no route matched.
|
|
13
|
+
func RoutePattern(r *http.Request) string {
|
|
14
|
+
if rc := chi.RouteContext(r.Context()); rc != nil {
|
|
15
|
+
return rc.RoutePattern()
|
|
16
|
+
}
|
|
17
|
+
return ""
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// ClientIP is the TCP peer. Trusted-proxy handling is not decided yet.
|
|
21
|
+
func ClientIP(r *http.Request) netip.Addr {
|
|
22
|
+
ap, err := netip.ParseAddrPort(r.RemoteAddr)
|
|
23
|
+
if err != nil {
|
|
24
|
+
return netip.Addr{}
|
|
25
|
+
}
|
|
26
|
+
return ap.Addr().Unmap()
|
|
27
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// Implements PRD §6.2 clean-up and counting. The back end (Go) implements the
|
|
2
|
+
// same rules; both run testdata/text-cleanup.json.
|
|
3
|
+
// String.prototype.trim is NOT used: its whitespace set differs from Unicode
|
|
4
|
+
// White_Space (it strips U+FEFF and keeps U+0085).
|
|
5
|
+
|
|
6
|
+
export type Problem = '' | 'REQUIRED' | 'INVALID_FORMAT' | 'INVALID_NAME_CHARS' | 'OUT_OF_RANGE';
|
|
7
|
+
|
|
8
|
+
export interface CheckResult {
|
|
9
|
+
cleaned: string;
|
|
10
|
+
problem: Problem;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Unicode White_Space property: 25 code points. */
|
|
14
|
+
export function isWhitespace(cp: number): boolean {
|
|
15
|
+
return (
|
|
16
|
+
(cp >= 0x0009 && cp <= 0x000d) ||
|
|
17
|
+
cp === 0x0020 ||
|
|
18
|
+
cp === 0x0085 ||
|
|
19
|
+
cp === 0x00a0 ||
|
|
20
|
+
cp === 0x1680 ||
|
|
21
|
+
(cp >= 0x2000 && cp <= 0x200a) ||
|
|
22
|
+
cp === 0x2028 ||
|
|
23
|
+
cp === 0x2029 ||
|
|
24
|
+
cp === 0x202f ||
|
|
25
|
+
cp === 0x205f ||
|
|
26
|
+
cp === 0x3000
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Characters that would end up in the expected filename (PRD §6.2 rule 8). */
|
|
31
|
+
const FORBIDDEN_NAME_CHARS = /[/\\:*?"<>|]/;
|
|
32
|
+
const CONTROL = /\p{Cc}/u;
|
|
33
|
+
|
|
34
|
+
/** Length in Unicode code points (not UTF-16 units). */
|
|
35
|
+
export function countCodePoints(s: string): number {
|
|
36
|
+
return Array.from(s).length;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Removes leading and trailing White_Space code points. */
|
|
40
|
+
export function trimWhitespace(s: string): string {
|
|
41
|
+
const cps = Array.from(s);
|
|
42
|
+
let start = 0;
|
|
43
|
+
let end = cps.length;
|
|
44
|
+
while (start < end && isWhitespace(cps[start]!.codePointAt(0)!)) start++;
|
|
45
|
+
while (end > start && isWhitespace(cps[end - 1]!.codePointAt(0)!)) end--;
|
|
46
|
+
return cps.slice(start, end).join('');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** NFC, trim, then every whitespace run becomes one ASCII space. */
|
|
50
|
+
export function cleanName(s: string): string {
|
|
51
|
+
const trimmed = trimWhitespace(s.normalize('NFC'));
|
|
52
|
+
let out = '';
|
|
53
|
+
let inRun = false;
|
|
54
|
+
for (const ch of trimmed) {
|
|
55
|
+
if (isWhitespace(ch.codePointAt(0)!)) {
|
|
56
|
+
inRun = true;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
if (inRun) {
|
|
60
|
+
out += ' ';
|
|
61
|
+
inRun = false;
|
|
62
|
+
}
|
|
63
|
+
out += ch;
|
|
64
|
+
}
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** NFC, CRLF and CR become LF, then trim. */
|
|
69
|
+
export function cleanSummary(s: string): string {
|
|
70
|
+
return trimWhitespace(s.normalize('NFC').replace(/\r\n/g, '\n').replace(/\r/g, '\n'));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function check(cleaned: string, max: number, name: boolean): Problem {
|
|
74
|
+
if (cleaned === '') return 'REQUIRED';
|
|
75
|
+
for (const ch of cleaned) {
|
|
76
|
+
if (CONTROL.test(ch) && (name || ch !== '\n')) return 'INVALID_FORMAT';
|
|
77
|
+
}
|
|
78
|
+
if (name && FORBIDDEN_NAME_CHARS.test(cleaned)) return 'INVALID_NAME_CHARS';
|
|
79
|
+
if (countCodePoints(cleaned) > max) return 'OUT_OF_RANGE';
|
|
80
|
+
return '';
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Cleans a person / Idea name and reports its first problem; `max` is inclusive. */
|
|
84
|
+
export function checkName(s: string, max: number): CheckResult {
|
|
85
|
+
const cleaned = cleanName(s);
|
|
86
|
+
return { cleaned, problem: check(cleaned, max, true) };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Cleans a summary and reports its first problem; `max` is inclusive. */
|
|
90
|
+
export function checkSummary(s: string, max: number): CheckResult {
|
|
91
|
+
const cleaned = cleanSummary(s);
|
|
92
|
+
return { cleaned, problem: check(cleaned, max, false) };
|
|
93
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import cleanup from 'testdata/text-cleanup.json';
|
|
2
|
+
import { checkName, checkSummary, countCodePoints, trimWhitespace } from './index';
|
|
3
|
+
|
|
4
|
+
describe('shared/text contract (testdata/text-cleanup.json)', () => {
|
|
5
|
+
it('has cases', () => {
|
|
6
|
+
expect(cleanup.cases.length).toBeGreaterThan(0);
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it.each(cleanup.cases)('$id', (c) => {
|
|
10
|
+
const r = c.kind === 'name' ? checkName(c.input, c.max) : checkSummary(c.input, c.max);
|
|
11
|
+
expect(r.cleaned).toBe(c.want);
|
|
12
|
+
expect(countCodePoints(r.cleaned)).toBe(c.wantCount);
|
|
13
|
+
expect(r.problem).toBe(c.wantProblem);
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
describe('trimWhitespace', () => {
|
|
18
|
+
it('uses White_Space, not String.prototype.trim', () => {
|
|
19
|
+
expect(trimWhitespace('\u0085a\u0085')).toBe('a');
|
|
20
|
+
expect(trimWhitespace('a')).toBe('a');
|
|
21
|
+
});
|
|
22
|
+
});
|
package/package.json
CHANGED
|
File without changes
|