@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,53 @@
|
|
|
1
|
+
// Package idea owns the idea table and is the only way to read an Idea
|
|
2
|
+
// (Backend.md 8): every read goes through the visibility query.
|
|
3
|
+
package idea
|
|
4
|
+
|
|
5
|
+
import (
|
|
6
|
+
"context"
|
|
7
|
+
"database/sql"
|
|
8
|
+
"errors"
|
|
9
|
+
|
|
10
|
+
"github.com/google/uuid"
|
|
11
|
+
|
|
12
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/audit"
|
|
13
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/deadline"
|
|
14
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/idea/internal/store"
|
|
15
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/shared/security"
|
|
16
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/shared/web"
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
// Idea is the module's read model of one Idea.
|
|
20
|
+
type Idea = store.Idea
|
|
21
|
+
|
|
22
|
+
// Service reads and creates Ideas.
|
|
23
|
+
type Service struct {
|
|
24
|
+
DB *sql.DB
|
|
25
|
+
Audit *audit.Service
|
|
26
|
+
// Gate, Slides, Objects and Aborter are needed by Create only.
|
|
27
|
+
Gate deadline.SubmissionGate
|
|
28
|
+
Slides SlideRules
|
|
29
|
+
Objects ObjectStore
|
|
30
|
+
Aborter UploadAborter
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Get returns the Idea if the caller may see it. A missing Idea and a hidden
|
|
34
|
+
// one both return the same NOT_FOUND error; the handler audits it as DENIED.
|
|
35
|
+
func (s *Service) Get(ctx context.Context, id uuid.UUID, caller security.Principal) (Idea, error) {
|
|
36
|
+
got, err := store.GetVisible(ctx, s.DB, id, caller.UserID, caller.IsAdmin())
|
|
37
|
+
if errors.Is(err, store.ErrNotFound) {
|
|
38
|
+
return Idea{}, web.NewError(web.CodeNotFound, "")
|
|
39
|
+
}
|
|
40
|
+
return got, err
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Summary is the module's read model of one list row.
|
|
44
|
+
type Summary = store.Summary
|
|
45
|
+
|
|
46
|
+
// ListFilter is the validated filter set of the list.
|
|
47
|
+
type ListFilter = store.ListFilter
|
|
48
|
+
|
|
49
|
+
// List returns one page of the Ideas the caller may see and the total match
|
|
50
|
+
// count. limit and offset are already validated by the handler.
|
|
51
|
+
func (s *Service) List(ctx context.Context, caller security.Principal, f ListFilter, limit, offset int) ([]Summary, int, error) {
|
|
52
|
+
return store.ListVisible(ctx, s.DB, caller.UserID, caller.IsAdmin(), f, limit, offset)
|
|
53
|
+
}
|
|
@@ -7,6 +7,7 @@ import (
|
|
|
7
7
|
"github.com/brian-huanggg/AwardRegistration/backend/internal/api"
|
|
8
8
|
"github.com/brian-huanggg/AwardRegistration/backend/internal/auth"
|
|
9
9
|
"github.com/brian-huanggg/AwardRegistration/backend/internal/deadline"
|
|
10
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/idea"
|
|
10
11
|
)
|
|
11
12
|
|
|
12
13
|
// API composes the module handlers into api.ServerInterface. Operations whose
|
|
@@ -19,6 +20,7 @@ type API struct {
|
|
|
19
20
|
Me auth.MeHandler
|
|
20
21
|
Account account.Handler
|
|
21
22
|
Deadline deadline.Handler
|
|
23
|
+
Ideas idea.Handler
|
|
22
24
|
}
|
|
23
25
|
|
|
24
26
|
// GetDeadline serves GET /deadline.
|
|
@@ -42,3 +44,18 @@ func (a API) Logout(w http.ResponseWriter, r *http.Request, _ api.LogoutParams)
|
|
|
42
44
|
|
|
43
45
|
// GetMe serves GET /auth/me.
|
|
44
46
|
func (a API) GetMe(w http.ResponseWriter, r *http.Request) { a.Me.GetMe(w, r) }
|
|
47
|
+
|
|
48
|
+
// GetIdea serves GET /ideas/{id}.
|
|
49
|
+
func (a API) GetIdea(w http.ResponseWriter, r *http.Request, id api.IdeaId) {
|
|
50
|
+
a.Ideas.GetIdea(w, r, id)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// CreateIdea serves POST /ideas.
|
|
54
|
+
func (a API) CreateIdea(w http.ResponseWriter, r *http.Request, _ api.CreateIdeaParams) {
|
|
55
|
+
a.Ideas.CreateIdea(w, r)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ListIdeas serves GET /ideas.
|
|
59
|
+
func (a API) ListIdeas(w http.ResponseWriter, r *http.Request, params api.ListIdeasParams) {
|
|
60
|
+
a.Ideas.ListIdeas(w, r, params)
|
|
61
|
+
}
|
|
@@ -31,6 +31,7 @@ var WriteActions = map[string]string{
|
|
|
31
31
|
http.MethodPost + " /api/v1/auth/login": "AUTH_LOGIN",
|
|
32
32
|
http.MethodPost + " /api/v1/auth/register": "AUTH_REGISTER",
|
|
33
33
|
http.MethodPost + " /api/v1/auth/logout": "AUTH_LOGOUT",
|
|
34
|
+
http.MethodPost + " /api/v1/ideas": "IDEA_CREATE",
|
|
34
35
|
}
|
|
35
36
|
|
|
36
37
|
// ActionFor returns the audit action of r, or "" when unknown.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
package server
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/idea"
|
|
5
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/storage"
|
|
6
|
+
)
|
|
7
|
+
|
|
8
|
+
// IdeaSlideRules adapts the storage slide rules to idea.SlideRules. It lives
|
|
9
|
+
// here because idea must not import storage (D29) and storage must not know
|
|
10
|
+
// idea's types.
|
|
11
|
+
func IdeaSlideRules() idea.SlideRules {
|
|
12
|
+
return idea.SlideRules{
|
|
13
|
+
ExpectedBase: storage.ExpectedFilenameBase,
|
|
14
|
+
CheckLength: storage.CheckFilenameLength,
|
|
15
|
+
Validate: func(f idea.SlideFile, expectedBase string) (string, error) {
|
|
16
|
+
// ReadMultipart guarantees exactly one part (step 1, 400).
|
|
17
|
+
kind, err := storage.Validate([]storage.Part{{Filename: f.Filename, Size: f.Size, Data: f.Data}}, expectedBase)
|
|
18
|
+
return string(kind), err
|
|
19
|
+
},
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -5,6 +5,7 @@ import (
|
|
|
5
5
|
"fmt"
|
|
6
6
|
"log/slog"
|
|
7
7
|
"os"
|
|
8
|
+
"strconv"
|
|
8
9
|
"strings"
|
|
9
10
|
"time"
|
|
10
11
|
)
|
|
@@ -20,13 +21,32 @@ type Config struct {
|
|
|
20
21
|
ReadyDrainDelay time.Duration // time readiness stays false before Shutdown starts
|
|
21
22
|
CSRFKey string // HMAC key that signs CSRF tokens (>= 32 bytes)
|
|
22
23
|
CSRFKeyPrevious string // optional: still verifies for 8 h after a rotation
|
|
24
|
+
ProxyHops int // X-Forwarded-For proxies to trust for the client IP; 0 = TCP peer (PRD §6.5)
|
|
25
|
+
MinIO MinIO
|
|
23
26
|
}
|
|
24
27
|
|
|
28
|
+
// MinIO configures the slide store. The bucket must exist; the application
|
|
29
|
+
// never creates it.
|
|
30
|
+
type MinIO struct {
|
|
31
|
+
Endpoint string // host:port
|
|
32
|
+
AccessKey string
|
|
33
|
+
SecretKey string
|
|
34
|
+
Bucket string
|
|
35
|
+
Secure bool // TLS to MinIO
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// DefaultMinIOKey is the access and secret key of docker-compose.yml, used by
|
|
39
|
+
// the local profile only; every other profile must set both keys.
|
|
40
|
+
const DefaultMinIOKey = "minioadmin"
|
|
41
|
+
|
|
25
42
|
// DefaultCSRFKey is the throwaway key of the local profile only; every other
|
|
26
43
|
// profile must set ESG_CSRF_KEY.
|
|
27
44
|
const DefaultCSRFKey = "local-dev-csrf-key-not-a-secret-0000"
|
|
28
45
|
|
|
29
|
-
const
|
|
46
|
+
const (
|
|
47
|
+
minCSRFKeyBytes = 32
|
|
48
|
+
maxProxyHops = 10
|
|
49
|
+
)
|
|
30
50
|
|
|
31
51
|
// DefaultDSN matches docker-compose.yml (throwaway local credentials). The query
|
|
32
52
|
// pins UTC and matched-row counts (Backend.md §2.3).
|
|
@@ -47,6 +67,12 @@ func Load(getenv func(string) string) (Config, error) {
|
|
|
47
67
|
DBDSN: get("ESG_DB_DSN", DefaultDSN),
|
|
48
68
|
CSRFKey: getenv("ESG_CSRF_KEY"),
|
|
49
69
|
CSRFKeyPrevious: getenv("ESG_CSRF_KEY_PREVIOUS"),
|
|
70
|
+
MinIO: MinIO{
|
|
71
|
+
Endpoint: get("ESG_MINIO_ENDPOINT", "localhost:9000"),
|
|
72
|
+
AccessKey: getenv("ESG_MINIO_ACCESS_KEY"),
|
|
73
|
+
SecretKey: getenv("ESG_MINIO_SECRET_KEY"),
|
|
74
|
+
Bucket: get("ESG_MINIO_BUCKET", "esg-slides"),
|
|
75
|
+
},
|
|
50
76
|
}
|
|
51
77
|
switch c.Profile {
|
|
52
78
|
case "local", "k3s", "dev":
|
|
@@ -56,6 +82,17 @@ func Load(getenv func(string) string) (Config, error) {
|
|
|
56
82
|
if c.CSRFKey == "" && c.Profile == "local" {
|
|
57
83
|
c.CSRFKey = DefaultCSRFKey
|
|
58
84
|
}
|
|
85
|
+
if c.Profile == "local" {
|
|
86
|
+
if c.MinIO.AccessKey == "" {
|
|
87
|
+
c.MinIO.AccessKey = DefaultMinIOKey
|
|
88
|
+
}
|
|
89
|
+
if c.MinIO.SecretKey == "" {
|
|
90
|
+
c.MinIO.SecretKey = DefaultMinIOKey
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
if c.MinIO.AccessKey == "" || c.MinIO.SecretKey == "" {
|
|
94
|
+
return Config{}, fmt.Errorf("ESG_MINIO_ACCESS_KEY and ESG_MINIO_SECRET_KEY are required outside the local profile")
|
|
95
|
+
}
|
|
59
96
|
if len(c.CSRFKey) < minCSRFKeyBytes {
|
|
60
97
|
return Config{}, fmt.Errorf("ESG_CSRF_KEY: need at least %d bytes", minCSRFKeyBytes)
|
|
61
98
|
}
|
|
@@ -66,6 +103,12 @@ func Load(getenv func(string) string) (Config, error) {
|
|
|
66
103
|
return Config{}, fmt.Errorf("ESG_LOG_LEVEL: %w", err)
|
|
67
104
|
}
|
|
68
105
|
var err error
|
|
106
|
+
if c.ProxyHops, err = strconv.Atoi(get("ESG_TRUSTED_PROXY_HOPS", "1")); err != nil || c.ProxyHops < 0 || c.ProxyHops > maxProxyHops {
|
|
107
|
+
return Config{}, fmt.Errorf("ESG_TRUSTED_PROXY_HOPS: want an integer 0..%d", maxProxyHops)
|
|
108
|
+
}
|
|
109
|
+
if c.MinIO.Secure, err = strconv.ParseBool(get("ESG_MINIO_SECURE", "false")); err != nil {
|
|
110
|
+
return Config{}, fmt.Errorf("ESG_MINIO_SECURE: %w", err)
|
|
111
|
+
}
|
|
69
112
|
if c.ShutdownTimeout, err = time.ParseDuration(get("ESG_SHUTDOWN_TIMEOUT", "20s")); err != nil {
|
|
70
113
|
return Config{}, fmt.Errorf("ESG_SHUTDOWN_TIMEOUT: %w", err)
|
|
71
114
|
}
|
|
@@ -18,32 +18,40 @@ func TestDefaults(t *testing.T) {
|
|
|
18
18
|
t.Fatal(err)
|
|
19
19
|
}
|
|
20
20
|
if c.Profile != "local" || c.PublicAddr != ":8080" || c.ManagementAddr != ":8081" ||
|
|
21
|
-
c.LogLevel != slog.LevelInfo || c.ShutdownTimeout != 20*time.Second || c.CSRFKey != config.DefaultCSRFKey
|
|
21
|
+
c.LogLevel != slog.LevelInfo || c.ShutdownTimeout != 20*time.Second || c.CSRFKey != config.DefaultCSRFKey || c.ProxyHops != 1 ||
|
|
22
|
+
c.MinIO != (config.MinIO{Endpoint: "localhost:9000", AccessKey: "minioadmin", SecretKey: "minioadmin", Bucket: "esg-slides"}) {
|
|
22
23
|
t.Fatalf("%+v", c)
|
|
23
24
|
}
|
|
24
25
|
}
|
|
25
26
|
|
|
26
27
|
func TestOverrides(t *testing.T) {
|
|
27
28
|
c, err := config.Load(env(map[string]string{
|
|
28
|
-
"ESG_PROFILE": "dev", "ESG_CSRF_KEY": key, "ESG_CSRF_KEY_PREVIOUS": key + "2", "ESG_LOG_LEVEL": "debug", "ESG_SHUTDOWN_TIMEOUT": "5s", "ESG_READY_DRAIN_DELAY": "1s",
|
|
29
|
+
"ESG_PROFILE": "dev", "ESG_CSRF_KEY": key, "ESG_CSRF_KEY_PREVIOUS": key + "2", "ESG_LOG_LEVEL": "debug", "ESG_SHUTDOWN_TIMEOUT": "5s", "ESG_READY_DRAIN_DELAY": "1s", "ESG_TRUSTED_PROXY_HOPS": "2",
|
|
30
|
+
"ESG_MINIO_ENDPOINT": "minio:9000", "ESG_MINIO_ACCESS_KEY": "a", "ESG_MINIO_SECRET_KEY": "s", "ESG_MINIO_BUCKET": "b", "ESG_MINIO_SECURE": "true",
|
|
29
31
|
}))
|
|
30
|
-
if err != nil || c.Profile != "dev" || c.LogLevel != slog.LevelDebug || c.ReadyDrainDelay != time.Second
|
|
32
|
+
if err != nil || c.Profile != "dev" || c.LogLevel != slog.LevelDebug || c.ReadyDrainDelay != time.Second || c.ProxyHops != 2 ||
|
|
33
|
+
c.MinIO != (config.MinIO{Endpoint: "minio:9000", AccessKey: "a", SecretKey: "s", Bucket: "b", Secure: true}) {
|
|
31
34
|
t.Fatalf("%+v %v", c, err)
|
|
32
35
|
}
|
|
33
36
|
}
|
|
34
37
|
|
|
35
38
|
func TestInvalid(t *testing.T) {
|
|
36
39
|
for name, m := range map[string]map[string]string{
|
|
37
|
-
"profile":
|
|
38
|
-
"log level":
|
|
39
|
-
"timeout":
|
|
40
|
-
"zero timeout":
|
|
41
|
-
"drain":
|
|
42
|
-
"short csrf key":
|
|
43
|
-
"short previous key":
|
|
44
|
-
"dev needs csrf key":
|
|
45
|
-
"k3s needs csrf key":
|
|
46
|
-
"
|
|
40
|
+
"profile": {"ESG_PROFILE": "prod"},
|
|
41
|
+
"log level": {"ESG_LOG_LEVEL": "loud"},
|
|
42
|
+
"timeout": {"ESG_SHUTDOWN_TIMEOUT": "soon"},
|
|
43
|
+
"zero timeout": {"ESG_SHUTDOWN_TIMEOUT": "0s"},
|
|
44
|
+
"drain": {"ESG_READY_DRAIN_DELAY": "x"},
|
|
45
|
+
"short csrf key": {"ESG_CSRF_KEY": "short"},
|
|
46
|
+
"short previous key": {"ESG_CSRF_KEY": key, "ESG_CSRF_KEY_PREVIOUS": "short"},
|
|
47
|
+
"dev needs csrf key": {"ESG_PROFILE": "dev"},
|
|
48
|
+
"k3s needs csrf key": {"ESG_PROFILE": "k3s"},
|
|
49
|
+
"hops text": {"ESG_TRUSTED_PROXY_HOPS": "one"},
|
|
50
|
+
"negative hops": {"ESG_TRUSTED_PROXY_HOPS": "-1"},
|
|
51
|
+
"too many hops": {"ESG_TRUSTED_PROXY_HOPS": "11"},
|
|
52
|
+
"same address": {"ESG_PUBLIC_ADDR": ":9", "ESG_MANAGEMENT_ADDR": ":9"},
|
|
53
|
+
"dev needs minio keys": {"ESG_PROFILE": "dev", "ESG_CSRF_KEY": key},
|
|
54
|
+
"minio secure": {"ESG_MINIO_SECURE": "maybe"},
|
|
47
55
|
} {
|
|
48
56
|
if _, err := config.Load(env(m)); err == nil {
|
|
49
57
|
t.Errorf("%s: want error", name)
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
//go:build integration
|
|
2
|
+
|
|
3
|
+
package db_test
|
|
4
|
+
|
|
5
|
+
import (
|
|
6
|
+
"context"
|
|
7
|
+
"database/sql"
|
|
8
|
+
"errors"
|
|
9
|
+
"net"
|
|
10
|
+
"net/http"
|
|
11
|
+
"strings"
|
|
12
|
+
"testing"
|
|
13
|
+
"time"
|
|
14
|
+
|
|
15
|
+
"github.com/testcontainers/testcontainers-go"
|
|
16
|
+
"github.com/testcontainers/testcontainers-go/modules/toxiproxy"
|
|
17
|
+
"github.com/testcontainers/testcontainers-go/network"
|
|
18
|
+
|
|
19
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/shared/db"
|
|
20
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/shared/dbtest"
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
const toxiproxyImage = "ghcr.io/shopify/toxiproxy:2.12.0"
|
|
24
|
+
|
|
25
|
+
// insertLock writes one login_lock row (PK lock_key): a table with a primary
|
|
26
|
+
// key, so a second writer blocks while the first transaction is still open.
|
|
27
|
+
func insertLock(ctx context.Context, tx *sql.Tx, key string) error {
|
|
28
|
+
_, err := tx.ExecContext(ctx,
|
|
29
|
+
`INSERT INTO login_lock (lock_key, fail_count, last_fail_at) VALUES (?, 1, NOW(6))`, key)
|
|
30
|
+
return err
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
func lockRows(t *testing.T, d *sql.DB, key string) int {
|
|
34
|
+
t.Helper()
|
|
35
|
+
var n int
|
|
36
|
+
if err := d.QueryRow(`SELECT COUNT(*) FROM login_lock WHERE lock_key = ?`, key).Scan(&n); err != nil {
|
|
37
|
+
t.Fatal(err)
|
|
38
|
+
}
|
|
39
|
+
return n
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Backend.md §4.4 / §11: the classification against a real server, through the
|
|
43
|
+
// connector that commits-then-fails or fails-without-committing.
|
|
44
|
+
func TestInTxLostCommitAgainstMariaDB(t *testing.T) {
|
|
45
|
+
dsn := dbtest.Start(t)
|
|
46
|
+
direct := dbtest.Open(t, dsn)
|
|
47
|
+
faulty, faults := dbtest.OpenFaulty(t, dsn)
|
|
48
|
+
ctx := context.Background()
|
|
49
|
+
|
|
50
|
+
for _, tc := range []struct {
|
|
51
|
+
name string
|
|
52
|
+
fault *dbtest.CommitFault
|
|
53
|
+
want db.Outcome
|
|
54
|
+
applied bool
|
|
55
|
+
}{
|
|
56
|
+
{"no fault", nil, db.Committed, true},
|
|
57
|
+
{"commit applied, ack lost", &dbtest.LostAckCommitted, db.Unknown, true},
|
|
58
|
+
{"connection lost before commit", &dbtest.LostAckNotCommitted, db.Unknown, false},
|
|
59
|
+
{"deadlock at commit (SQLState 40)", &dbtest.DeadlockAtCommit, db.RolledBack, false},
|
|
60
|
+
} {
|
|
61
|
+
t.Run(tc.name, func(t *testing.T) {
|
|
62
|
+
key := "lost-" + strings.ReplaceAll(tc.name, " ", "-")
|
|
63
|
+
if len(key) > 32 {
|
|
64
|
+
key = key[:32]
|
|
65
|
+
}
|
|
66
|
+
if tc.fault != nil {
|
|
67
|
+
faults.FailNextCommit(*tc.fault)
|
|
68
|
+
}
|
|
69
|
+
out, err := db.InTx(ctx, faulty, func(ctx context.Context, tx *sql.Tx) error {
|
|
70
|
+
return insertLock(ctx, tx, key)
|
|
71
|
+
})
|
|
72
|
+
if out != tc.want {
|
|
73
|
+
t.Errorf("outcome = %v, want %v (err %v)", out, tc.want, err)
|
|
74
|
+
}
|
|
75
|
+
if (err != nil) != (tc.fault != nil) {
|
|
76
|
+
t.Errorf("err = %v", err)
|
|
77
|
+
}
|
|
78
|
+
if got := lockRows(t, direct, key) == 1; got != tc.applied {
|
|
79
|
+
t.Errorf("row applied = %v, want %v", got, tc.applied)
|
|
80
|
+
}
|
|
81
|
+
})
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
t.Run("fn error rolls back on the real server", func(t *testing.T) {
|
|
85
|
+
out, err := db.InTx(ctx, faulty, func(ctx context.Context, tx *sql.Tx) error {
|
|
86
|
+
if err := insertLock(ctx, tx, "lost-fn-error"); err != nil {
|
|
87
|
+
return err
|
|
88
|
+
}
|
|
89
|
+
return errors.New("audit insert failed")
|
|
90
|
+
})
|
|
91
|
+
if out != db.RolledBack || err == nil || lockRows(t, direct, "lost-fn-error") != 0 {
|
|
92
|
+
t.Errorf("got %v, %v", out, err)
|
|
93
|
+
}
|
|
94
|
+
})
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// One real-network sanity check (Sequence.md §7): Toxiproxy cuts the
|
|
98
|
+
// connection between the last statement and COMMIT. The driver sees a dead
|
|
99
|
+
// connection, InTx must say Unknown (never RolledBack: it cannot know), and the
|
|
100
|
+
// server must have rolled the transaction back.
|
|
101
|
+
func TestInTxConnectionCutBeforeCommit(t *testing.T) {
|
|
102
|
+
ctx := context.Background()
|
|
103
|
+
nw, err := network.New(ctx)
|
|
104
|
+
if err != nil {
|
|
105
|
+
t.Fatal(err)
|
|
106
|
+
}
|
|
107
|
+
t.Cleanup(func() { _ = nw.Remove(context.WithoutCancel(ctx)) })
|
|
108
|
+
|
|
109
|
+
direct := dbtest.Open(t, dbtest.Start(t, network.WithNetwork([]string{"mariadb"}, nw)))
|
|
110
|
+
|
|
111
|
+
tp, err := toxiproxy.Run(ctx, toxiproxyImage,
|
|
112
|
+
toxiproxy.WithProxy("mariadb", "mariadb:3306"),
|
|
113
|
+
network.WithNetwork([]string{"toxiproxy"}, nw))
|
|
114
|
+
testcontainers.CleanupContainer(t, tp)
|
|
115
|
+
if err != nil {
|
|
116
|
+
t.Fatalf("start toxiproxy: %v", err)
|
|
117
|
+
}
|
|
118
|
+
host, port, err := tp.ProxiedEndpoint(8666)
|
|
119
|
+
if err != nil {
|
|
120
|
+
t.Fatal(err)
|
|
121
|
+
}
|
|
122
|
+
api, err := tp.URI(ctx)
|
|
123
|
+
if err != nil {
|
|
124
|
+
t.Fatal(err)
|
|
125
|
+
}
|
|
126
|
+
proxied := dbtest.Open(t, dbtest.DSN(net.JoinHostPort(host, port)))
|
|
127
|
+
if err := proxied.PingContext(ctx); err != nil {
|
|
128
|
+
t.Fatalf("ping through toxiproxy: %v", err)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const key = "toxiproxy-cut"
|
|
132
|
+
out, err := db.InTx(ctx, proxied, func(ctx context.Context, tx *sql.Tx) error {
|
|
133
|
+
if err := insertLock(ctx, tx, key); err != nil {
|
|
134
|
+
return err
|
|
135
|
+
}
|
|
136
|
+
disableProxy(t, api) // the next bytes (COMMIT) go nowhere
|
|
137
|
+
return nil
|
|
138
|
+
})
|
|
139
|
+
if out != db.Unknown || err == nil {
|
|
140
|
+
t.Fatalf("InTx = %v, %v; want Unknown with an error", out, err)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// COMMIT never reached the server: once it notices the closed socket it
|
|
144
|
+
// rolls back and releases the row lock, so this insert stops blocking.
|
|
145
|
+
deadline := time.Now().Add(15 * time.Second)
|
|
146
|
+
for {
|
|
147
|
+
_, err := direct.ExecContext(ctx,
|
|
148
|
+
`SET STATEMENT innodb_lock_wait_timeout = 1 FOR
|
|
149
|
+
INSERT INTO login_lock (lock_key, fail_count, last_fail_at) VALUES (?, 1, NOW(6))`, key)
|
|
150
|
+
if err == nil {
|
|
151
|
+
break
|
|
152
|
+
}
|
|
153
|
+
if !db.IsContention(err) || time.Now().After(deadline) {
|
|
154
|
+
t.Fatalf("server did not roll the cut transaction back: %v", err)
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// disableProxy disables the proxy over the Toxiproxy HTTP API, which closes
|
|
160
|
+
// every connection through it.
|
|
161
|
+
func disableProxy(t *testing.T, api string) {
|
|
162
|
+
t.Helper()
|
|
163
|
+
body := `{"enabled":false}`
|
|
164
|
+
req, err := http.NewRequestWithContext(context.Background(), http.MethodPost,
|
|
165
|
+
api+"/proxies/mariadb", strings.NewReader(body))
|
|
166
|
+
if err != nil {
|
|
167
|
+
t.Fatal(err)
|
|
168
|
+
}
|
|
169
|
+
req.Header.Set("Content-Type", "application/json")
|
|
170
|
+
req.Header.Set("User-Agent", "toxiproxy-cli")
|
|
171
|
+
resp, err := (&http.Client{Timeout: 10 * time.Second}).Do(req)
|
|
172
|
+
if err != nil {
|
|
173
|
+
t.Fatal(err)
|
|
174
|
+
}
|
|
175
|
+
_ = resp.Body.Close()
|
|
176
|
+
if resp.StatusCode != http.StatusOK {
|
|
177
|
+
t.Fatalf("toxiproxy update: HTTP %d", resp.StatusCode)
|
|
178
|
+
}
|
|
179
|
+
}
|
|
@@ -13,6 +13,7 @@ import (
|
|
|
13
13
|
"testing"
|
|
14
14
|
|
|
15
15
|
_ "github.com/go-sql-driver/mysql"
|
|
16
|
+
"github.com/testcontainers/testcontainers-go"
|
|
16
17
|
"github.com/testcontainers/testcontainers-go/modules/mariadb"
|
|
17
18
|
|
|
18
19
|
"github.com/brian-huanggg/AwardRegistration/backend/internal/shared/config"
|
|
@@ -41,15 +42,26 @@ func MigrationFiles() ([]string, error) {
|
|
|
41
42
|
// New starts MariaDB, applies every migration and returns a pool that uses
|
|
42
43
|
// the production DSN. The container is terminated when the test ends.
|
|
43
44
|
func New(t testing.TB) *sql.DB {
|
|
45
|
+
t.Helper()
|
|
46
|
+
return Open(t, Start(t))
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Start starts MariaDB with every migration applied and returns the production
|
|
50
|
+
// DSN for it. opts customise the container (for example a Docker network, so a
|
|
51
|
+
// Toxiproxy container can reach it). The container is terminated when the test
|
|
52
|
+
// ends.
|
|
53
|
+
func Start(t testing.TB, opts ...testcontainers.ContainerCustomizer) string {
|
|
44
54
|
t.Helper()
|
|
45
55
|
ctx := context.Background()
|
|
46
56
|
scripts, err := MigrationFiles()
|
|
47
57
|
if err != nil || len(scripts) == 0 {
|
|
48
58
|
t.Fatalf("no migrations found: %v", err)
|
|
49
59
|
}
|
|
50
|
-
|
|
60
|
+
opts = append([]testcontainers.ContainerCustomizer{
|
|
51
61
|
mariadb.WithDatabase("esg"), mariadb.WithUsername("esg"), mariadb.WithPassword("esg"),
|
|
52
|
-
mariadb.WithScripts(scripts...)
|
|
62
|
+
mariadb.WithScripts(scripts...),
|
|
63
|
+
}, opts...)
|
|
64
|
+
ctr, err := mariadb.Run(ctx, Image, opts...)
|
|
53
65
|
if err != nil {
|
|
54
66
|
t.Fatalf("start mariadb: %v", err)
|
|
55
67
|
}
|
|
@@ -63,7 +75,13 @@ func New(t testing.TB) *sql.DB {
|
|
|
63
75
|
if u == nil {
|
|
64
76
|
t.Fatalf("unexpected connection string %q", cs)
|
|
65
77
|
}
|
|
66
|
-
|
|
78
|
+
return DSN(u[1])
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Open opens a pool on dsn, closed when the test ends.
|
|
82
|
+
func Open(t testing.TB, dsn string) *sql.DB {
|
|
83
|
+
t.Helper()
|
|
84
|
+
db, err := sql.Open("mysql", dsn)
|
|
67
85
|
if err != nil {
|
|
68
86
|
t.Fatal(err)
|
|
69
87
|
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
package dbtest
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"database/sql"
|
|
6
|
+
"database/sql/driver"
|
|
7
|
+
"errors"
|
|
8
|
+
"fmt"
|
|
9
|
+
"sync"
|
|
10
|
+
"testing"
|
|
11
|
+
|
|
12
|
+
"github.com/go-sql-driver/mysql"
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
// CommitFault is what the next COMMIT on a FaultyDB does (Backend.md §11,
|
|
16
|
+
// Sequence.md §7: lost-commit tests).
|
|
17
|
+
type CommitFault struct {
|
|
18
|
+
// Apply commits the transaction on the server before Err is returned; when
|
|
19
|
+
// false the transaction is rolled back instead and nothing is applied.
|
|
20
|
+
Apply bool
|
|
21
|
+
// Err is what Commit returns to database/sql.
|
|
22
|
+
Err error
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
var (
|
|
26
|
+
// LostAckCommitted: the server committed, the acknowledgement was lost.
|
|
27
|
+
LostAckCommitted = CommitFault{Apply: true, Err: mysql.ErrInvalidConn}
|
|
28
|
+
// LostAckNotCommitted: the connection died before the COMMIT was applied.
|
|
29
|
+
LostAckNotCommitted = CommitFault{Apply: false, Err: mysql.ErrInvalidConn}
|
|
30
|
+
// DeadlockAtCommit: the server rejected the COMMIT with SQLState 40001.
|
|
31
|
+
DeadlockAtCommit = CommitFault{Apply: false, Err: &mysql.MySQLError{
|
|
32
|
+
Number: 1213, SQLState: [5]byte{'4', '0', '0', '0', '1'}, Message: "Deadlock found (injected)"}}
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
// Faults arms commit faults on a pool opened by OpenFaulty. A fault applies to
|
|
36
|
+
// the next COMMIT on any connection of that pool, then disarms itself.
|
|
37
|
+
type Faults struct {
|
|
38
|
+
mu sync.Mutex
|
|
39
|
+
next *CommitFault
|
|
40
|
+
fired int
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// FailNextCommit arms f for the next COMMIT.
|
|
44
|
+
func (f *Faults) FailNextCommit(cf CommitFault) {
|
|
45
|
+
f.mu.Lock()
|
|
46
|
+
defer f.mu.Unlock()
|
|
47
|
+
f.next = &cf
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Fired reports how many armed faults have been triggered.
|
|
51
|
+
func (f *Faults) Fired() int {
|
|
52
|
+
f.mu.Lock()
|
|
53
|
+
defer f.mu.Unlock()
|
|
54
|
+
return f.fired
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
func (f *Faults) take() *CommitFault {
|
|
58
|
+
f.mu.Lock()
|
|
59
|
+
defer f.mu.Unlock()
|
|
60
|
+
cf := f.next
|
|
61
|
+
if cf != nil {
|
|
62
|
+
f.next = nil
|
|
63
|
+
f.fired++
|
|
64
|
+
}
|
|
65
|
+
return cf
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// OpenFaulty opens a pool on dsn whose driver.Connector wraps the MySQL one, so
|
|
69
|
+
// a COMMIT can be made to "commit then fail" or "fail without committing". The
|
|
70
|
+
// pool is closed when the test ends.
|
|
71
|
+
func OpenFaulty(t testing.TB, dsn string) (*sql.DB, *Faults) {
|
|
72
|
+
t.Helper()
|
|
73
|
+
cfg, err := mysql.ParseDSN(dsn)
|
|
74
|
+
if err != nil {
|
|
75
|
+
t.Fatal(err)
|
|
76
|
+
}
|
|
77
|
+
inner, err := mysql.NewConnector(cfg)
|
|
78
|
+
if err != nil {
|
|
79
|
+
t.Fatal(err)
|
|
80
|
+
}
|
|
81
|
+
f := &Faults{}
|
|
82
|
+
db := sql.OpenDB(faultConnector{inner: inner, f: f})
|
|
83
|
+
t.Cleanup(func() { _ = db.Close() })
|
|
84
|
+
return db, f
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
type faultConnector struct {
|
|
88
|
+
inner driver.Connector
|
|
89
|
+
f *Faults
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
func (c faultConnector) Driver() driver.Driver { return c.inner.Driver() }
|
|
93
|
+
|
|
94
|
+
func (c faultConnector) Connect(ctx context.Context) (driver.Conn, error) {
|
|
95
|
+
conn, err := c.inner.Connect(ctx)
|
|
96
|
+
if err != nil {
|
|
97
|
+
return nil, err
|
|
98
|
+
}
|
|
99
|
+
fc, ok := conn.(fullConn)
|
|
100
|
+
if !ok {
|
|
101
|
+
_ = conn.Close()
|
|
102
|
+
return nil, fmt.Errorf("dbtest: %T lacks a driver interface the wrapper forwards", conn)
|
|
103
|
+
}
|
|
104
|
+
return faultConn{fullConn: fc, f: c.f}, nil
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// fullConn is every optional interface the MySQL connection implements;
|
|
108
|
+
// embedding it forwards them all, so database/sql takes the same code paths as
|
|
109
|
+
// with the plain driver.
|
|
110
|
+
type fullConn interface {
|
|
111
|
+
driver.Conn
|
|
112
|
+
driver.ConnBeginTx
|
|
113
|
+
driver.ConnPrepareContext
|
|
114
|
+
driver.ExecerContext
|
|
115
|
+
driver.QueryerContext
|
|
116
|
+
driver.Pinger
|
|
117
|
+
driver.SessionResetter
|
|
118
|
+
driver.Validator
|
|
119
|
+
driver.NamedValueChecker
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
type faultConn struct {
|
|
123
|
+
fullConn
|
|
124
|
+
f *Faults
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
func (c faultConn) Begin() (driver.Tx, error) {
|
|
128
|
+
return c.BeginTx(context.Background(), driver.TxOptions{})
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
func (c faultConn) BeginTx(ctx context.Context, opts driver.TxOptions) (driver.Tx, error) {
|
|
132
|
+
tx, err := c.fullConn.BeginTx(ctx, opts)
|
|
133
|
+
if err != nil {
|
|
134
|
+
return nil, err
|
|
135
|
+
}
|
|
136
|
+
return faultTx{inner: tx, f: c.f}, nil
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
type faultTx struct {
|
|
140
|
+
inner driver.Tx
|
|
141
|
+
f *Faults
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
func (t faultTx) Rollback() error { return t.inner.Rollback() }
|
|
145
|
+
|
|
146
|
+
func (t faultTx) Commit() error {
|
|
147
|
+
cf := t.f.take()
|
|
148
|
+
if cf == nil {
|
|
149
|
+
return t.inner.Commit()
|
|
150
|
+
}
|
|
151
|
+
if cf.Apply {
|
|
152
|
+
if err := t.inner.Commit(); err != nil {
|
|
153
|
+
return fmt.Errorf("dbtest: real commit failed before the injected fault: %w", err)
|
|
154
|
+
}
|
|
155
|
+
return cf.Err
|
|
156
|
+
}
|
|
157
|
+
// Roll back so the locks are released; the server never applies it.
|
|
158
|
+
if err := t.inner.Rollback(); err != nil {
|
|
159
|
+
return errors.Join(cf.Err, err)
|
|
160
|
+
}
|
|
161
|
+
return cf.Err
|
|
162
|
+
}
|