@johnnyboy11234/esg-idea-contest 0.3.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/backend/api/openapi.yaml +83 -0
  2. package/backend/cmd/server/main.go +35 -2
  3. package/backend/db/migration/V005__idea.sql +49 -0
  4. package/backend/db/migration/V006__login_lock.sql +15 -0
  5. package/backend/db/migration/V007__login_attempt.sql +15 -0
  6. package/backend/db/migration/V008__pending_file_deletion.sql +27 -0
  7. package/backend/db/migration/V009__login_throttle_audit.sql +13 -0
  8. package/backend/go.mod +17 -3
  9. package/backend/go.sum +48 -7
  10. package/backend/internal/account/account_test.go +6 -6
  11. package/backend/internal/account/integration_test.go +45 -9
  12. package/backend/internal/account/keyboard.go +75 -0
  13. package/backend/internal/account/keyboard_test.go +43 -0
  14. package/backend/internal/account/logging_integration_test.go +1 -1
  15. package/backend/internal/account/password.go +125 -0
  16. package/backend/internal/account/validate.go +4 -21
  17. package/backend/internal/api/api.gen.go +224 -0
  18. package/backend/internal/auth/internal/store/login_attempt.go +53 -0
  19. package/backend/internal/auth/internal/store/login_lock.go +77 -0
  20. package/backend/internal/auth/internal/store/login_throttle_audit.go +42 -0
  21. package/backend/internal/auth/internal/store/session.go +15 -7
  22. package/backend/internal/auth/login.go +16 -4
  23. package/backend/internal/auth/login_integration_test.go +14 -1
  24. package/backend/internal/auth/login_ip_integration_test.go +153 -0
  25. package/backend/internal/auth/login_lock_integration_test.go +205 -0
  26. package/backend/internal/auth/login_test.go +15 -0
  27. package/backend/internal/auth/login_throttle_audit_integration_test.go +88 -0
  28. package/backend/internal/auth/purge.go +93 -0
  29. package/backend/internal/auth/purge_integration_test.go +220 -0
  30. package/backend/internal/auth/purge_test.go +51 -0
  31. package/backend/internal/auth/session.go +0 -6
  32. package/backend/internal/auth/session_integration_test.go +3 -3
  33. package/backend/internal/auth/throttle.go +122 -0
  34. package/backend/internal/deadline/gate.go +80 -0
  35. package/backend/internal/deadline/gate_integration_test.go +67 -0
  36. package/backend/internal/deadline/gate_test.go +61 -0
  37. package/backend/internal/idea/create.go +352 -0
  38. package/backend/internal/idea/create_integration_test.go +650 -0
  39. package/backend/internal/idea/handler.go +148 -0
  40. package/backend/internal/idea/handler_integration_test.go +107 -0
  41. package/backend/internal/idea/integration_test.go +149 -0
  42. package/backend/internal/idea/internal/store/list.go +146 -0
  43. package/backend/internal/idea/internal/store/list_test.go +33 -0
  44. package/backend/internal/idea/internal/store/store.go +166 -0
  45. package/backend/internal/idea/list.go +166 -0
  46. package/backend/internal/idea/list_integration_test.go +252 -0
  47. package/backend/internal/idea/list_test.go +96 -0
  48. package/backend/internal/idea/service.go +53 -0
  49. package/backend/internal/server/api.go +17 -0
  50. package/backend/internal/server/public.go +1 -0
  51. package/backend/internal/server/slides.go +21 -0
  52. package/backend/internal/shared/config/config.go +44 -1
  53. package/backend/internal/shared/config/config_test.go +21 -13
  54. package/backend/internal/shared/db/intx_integration_test.go +179 -0
  55. package/backend/internal/shared/dbtest/dbtest.go +21 -3
  56. package/backend/internal/shared/dbtest/faults.go +162 -0
  57. package/backend/internal/shared/job/job.go +54 -0
  58. package/backend/internal/shared/job/job_test.go +105 -0
  59. package/backend/internal/shared/web/multipart.go +175 -0
  60. package/backend/internal/shared/web/multipart_test.go +169 -0
  61. package/backend/internal/shared/web/problem.go +1 -0
  62. package/backend/internal/shared/web/request.go +42 -1
  63. package/backend/internal/shared/web/web_test.go +36 -0
  64. package/backend/internal/storage/expectedfilename.go +43 -0
  65. package/backend/internal/storage/expectedfilename_test.go +16 -0
  66. package/backend/internal/storage/internal/store/pending.go +41 -0
  67. package/backend/internal/storage/minio.go +139 -0
  68. package/backend/internal/storage/minio_integration_test.go +87 -0
  69. package/backend/internal/storage/minio_test.go +84 -0
  70. package/backend/internal/storage/pending.go +144 -0
  71. package/backend/internal/storage/pending_integration_test.go +324 -0
  72. package/backend/internal/storage/pending_test.go +23 -0
  73. package/backend/internal/storage/validator.go +100 -0
  74. package/backend/internal/storage/validator_test.go +95 -0
  75. package/backend/test/contract/expected_filename_test.go +60 -0
  76. package/backend/test/contract/password_test.go +46 -0
  77. package/backend/test/matrix/load_test.go +12 -0
  78. package/backend/test/matrix/matrix_integration_test.go +86 -5
  79. package/backend/test/migration/pending_file_deletion_test.go +46 -0
  80. package/docs/API/api-endpoints.md +1 -1
  81. package/docs/API/error-codes.md +1 -1
  82. package/frontend/src/app/App.tsx +11 -2
  83. package/frontend/src/features/auth/IdleWarning.test.tsx +82 -0
  84. package/frontend/src/features/auth/IdleWarning.tsx +66 -0
  85. package/frontend/src/shared/api/activity.ts +20 -0
  86. package/frontend/src/shared/api/http.ts +2 -0
  87. package/frontend/src/shared/api/index.ts +1 -0
  88. package/frontend/src/shared/i18n/locales/en.ts +1 -0
  89. package/frontend/src/shared/i18n/locales/zh-TW.ts +1 -0
  90. package/package.json +1 -1
  91. package/testdata/expected-filename.json +93 -0
  92. package/testdata/password.json +425 -0
  93. package/testdata/permission-matrix.json +48 -0
  94. package/testdata/schema/expected-filename.schema.json +32 -0
  95. package/testdata/schema/password.schema.json +59 -0
  96. package/testdata/schema/permission-matrix.schema.json +21 -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 minCSRFKeyBytes = 32
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": {"ESG_PROFILE": "prod"},
38
- "log level": {"ESG_LOG_LEVEL": "loud"},
39
- "timeout": {"ESG_SHUTDOWN_TIMEOUT": "soon"},
40
- "zero timeout": {"ESG_SHUTDOWN_TIMEOUT": "0s"},
41
- "drain": {"ESG_READY_DRAIN_DELAY": "x"},
42
- "short csrf key": {"ESG_CSRF_KEY": "short"},
43
- "short previous key": {"ESG_CSRF_KEY": key, "ESG_CSRF_KEY_PREVIOUS": "short"},
44
- "dev needs csrf key": {"ESG_PROFILE": "dev"},
45
- "k3s needs csrf key": {"ESG_PROFILE": "k3s"},
46
- "same address": {"ESG_PUBLIC_ADDR": ":9", "ESG_MANAGEMENT_ADDR": ":9"},
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
- ctr, err := mariadb.Run(ctx, Image,
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
- db, err := sql.Open("mysql", DSN(u[1]))
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
+ }