@johnnyboy11234/esg-idea-contest 0.3.0 → 0.3.2

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 (138) hide show
  1. package/backend/api/openapi.yaml +119 -0
  2. package/backend/cmd/server/main.go +36 -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/internal/passwords/NOTICE +36 -0
  13. package/backend/internal/account/internal/passwords/common-passwords.txt +10001 -0
  14. package/backend/internal/account/internal/passwords/passwords.go +37 -0
  15. package/backend/internal/account/internal/passwords/passwords_test.go +35 -0
  16. package/backend/internal/account/keyboard.go +75 -0
  17. package/backend/internal/account/keyboard_test.go +43 -0
  18. package/backend/internal/account/logging_integration_test.go +1 -1
  19. package/backend/internal/account/password.go +131 -0
  20. package/backend/internal/account/sequence.go +54 -0
  21. package/backend/internal/account/sequence_test.go +38 -0
  22. package/backend/internal/account/validate.go +4 -21
  23. package/backend/internal/api/api.gen.go +262 -0
  24. package/backend/internal/auth/internal/store/login_attempt.go +53 -0
  25. package/backend/internal/auth/internal/store/login_lock.go +77 -0
  26. package/backend/internal/auth/internal/store/login_throttle_audit.go +42 -0
  27. package/backend/internal/auth/internal/store/session.go +15 -7
  28. package/backend/internal/auth/login.go +16 -4
  29. package/backend/internal/auth/login_integration_test.go +14 -1
  30. package/backend/internal/auth/login_ip_integration_test.go +153 -0
  31. package/backend/internal/auth/login_lock_integration_test.go +205 -0
  32. package/backend/internal/auth/login_test.go +15 -0
  33. package/backend/internal/auth/login_throttle_audit_integration_test.go +88 -0
  34. package/backend/internal/auth/purge.go +93 -0
  35. package/backend/internal/auth/purge_integration_test.go +220 -0
  36. package/backend/internal/auth/purge_test.go +51 -0
  37. package/backend/internal/auth/session.go +0 -6
  38. package/backend/internal/auth/session_integration_test.go +3 -3
  39. package/backend/internal/auth/throttle.go +122 -0
  40. package/backend/internal/deadline/gate.go +80 -0
  41. package/backend/internal/deadline/gate_integration_test.go +67 -0
  42. package/backend/internal/deadline/gate_test.go +61 -0
  43. package/backend/internal/idea/create.go +356 -0
  44. package/backend/internal/idea/create_integration_test.go +680 -0
  45. package/backend/internal/idea/download.go +193 -0
  46. package/backend/internal/idea/download_integration_test.go +311 -0
  47. package/backend/internal/idea/download_test.go +25 -0
  48. package/backend/internal/idea/handler.go +151 -0
  49. package/backend/internal/idea/handler_integration_test.go +107 -0
  50. package/backend/internal/idea/integration_test.go +149 -0
  51. package/backend/internal/idea/internal/store/list.go +146 -0
  52. package/backend/internal/idea/internal/store/list_test.go +33 -0
  53. package/backend/internal/idea/internal/store/store.go +166 -0
  54. package/backend/internal/idea/list.go +166 -0
  55. package/backend/internal/idea/list_integration_test.go +252 -0
  56. package/backend/internal/idea/list_test.go +96 -0
  57. package/backend/internal/idea/service.go +54 -0
  58. package/backend/internal/server/api.go +22 -0
  59. package/backend/internal/server/binding.go +4 -0
  60. package/backend/internal/server/public.go +1 -0
  61. package/backend/internal/server/server.go +20 -1
  62. package/backend/internal/server/slides.go +21 -0
  63. package/backend/internal/server/slides_test.go +82 -0
  64. package/backend/internal/shared/config/config.go +44 -1
  65. package/backend/internal/shared/config/config_test.go +21 -13
  66. package/backend/internal/shared/db/intx_integration_test.go +179 -0
  67. package/backend/internal/shared/dbtest/dbtest.go +21 -3
  68. package/backend/internal/shared/dbtest/faults.go +162 -0
  69. package/backend/internal/shared/job/job.go +54 -0
  70. package/backend/internal/shared/job/job_test.go +105 -0
  71. package/backend/internal/shared/web/multipart.go +175 -0
  72. package/backend/internal/shared/web/multipart_test.go +169 -0
  73. package/backend/internal/shared/web/problem.go +1 -0
  74. package/backend/internal/shared/web/request.go +42 -1
  75. package/backend/internal/shared/web/transport.go +4 -3
  76. package/backend/internal/shared/web/web_test.go +36 -0
  77. package/backend/internal/storage/expectedfilename.go +43 -0
  78. package/backend/internal/storage/expectedfilename_test.go +16 -0
  79. package/backend/internal/storage/internal/store/pending.go +41 -0
  80. package/backend/internal/storage/minio.go +139 -0
  81. package/backend/internal/storage/minio_integration_test.go +87 -0
  82. package/backend/internal/storage/minio_test.go +84 -0
  83. package/backend/internal/storage/pending.go +144 -0
  84. package/backend/internal/storage/pending_integration_test.go +324 -0
  85. package/backend/internal/storage/pending_test.go +23 -0
  86. package/backend/internal/storage/validator.go +100 -0
  87. package/backend/internal/storage/validator_test.go +95 -0
  88. package/backend/test/arch/arch_test.go +5 -1
  89. package/backend/test/contract/expected_filename_test.go +60 -0
  90. package/backend/test/contract/password_test.go +46 -0
  91. package/backend/test/matrix/load_test.go +12 -0
  92. package/backend/test/matrix/matrix_integration_test.go +92 -5
  93. package/backend/test/migration/pending_file_deletion_test.go +46 -0
  94. package/docs/API/api-endpoints.md +1 -1
  95. package/docs/API/error-codes.md +1 -1
  96. package/docs/Architecture/Architecture.md +1 -1
  97. package/docs/Architecture/Backend.md +2 -1
  98. package/frontend/Dockerfile +2 -0
  99. package/frontend/package-lock.json +1 -0
  100. package/frontend/package.json +1 -0
  101. package/frontend/src/app/App.tsx +11 -2
  102. package/frontend/src/app/pages/ideaCreate.test.tsx +308 -0
  103. package/frontend/src/app/pages/ideas.tsx +23 -9
  104. package/frontend/src/app/pages/login.test.tsx +38 -1
  105. package/frontend/src/app/pages/register.test.tsx +32 -0
  106. package/frontend/src/features/auth/IdleWarning.test.tsx +82 -0
  107. package/frontend/src/features/auth/IdleWarning.tsx +66 -0
  108. package/frontend/src/features/auth/LoginForm.tsx +21 -3
  109. package/frontend/src/features/auth/PasswordChecklist.test.tsx +73 -0
  110. package/frontend/src/features/auth/PasswordChecklist.tsx +96 -0
  111. package/frontend/src/features/auth/RegisterForm.tsx +11 -2
  112. package/frontend/src/features/auth/common-passwords.ts +31 -0
  113. package/frontend/src/features/auth/password-policy.test.ts +64 -0
  114. package/frontend/src/features/auth/password-policy.ts +168 -0
  115. package/frontend/src/features/auth/useCountdown.test.tsx +33 -0
  116. package/frontend/src/features/auth/useCountdown.ts +28 -0
  117. package/frontend/src/features/idea/CreateIdeaForm.tsx +279 -0
  118. package/frontend/src/features/idea/create.ts +123 -0
  119. package/frontend/src/shared/api/activity.ts +20 -0
  120. package/frontend/src/shared/api/http.ts +2 -0
  121. package/frontend/src/shared/api/index.ts +1 -0
  122. package/frontend/src/shared/api/types.generated.ts +319 -1
  123. package/frontend/src/shared/i18n/locales/en.ts +46 -4
  124. package/frontend/src/shared/i18n/locales/zh-TW.ts +46 -4
  125. package/frontend/src/shared/slides/index.ts +74 -0
  126. package/frontend/src/shared/slides/slides.test.ts +51 -0
  127. package/frontend/src/shared/ui/copy-button.tsx +52 -0
  128. package/frontend/src/shared/ui/native-select.tsx +27 -0
  129. package/frontend/src/shared/ui/textarea.tsx +19 -0
  130. package/frontend/src/test-setup.ts +37 -2
  131. package/frontend/vite.config.ts +3 -1
  132. package/package.json +1 -1
  133. package/testdata/expected-filename.json +93 -0
  134. package/testdata/password.json +582 -0
  135. package/testdata/permission-matrix.json +71 -0
  136. package/testdata/schema/expected-filename.schema.json +32 -0
  137. package/testdata/schema/password.schema.json +59 -0
  138. package/testdata/schema/permission-matrix.schema.json +22 -1
@@ -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
+ }
@@ -0,0 +1,82 @@
1
+ package server_test
2
+
3
+ import (
4
+ "log/slog"
5
+ "net/http"
6
+ "net/http/httptest"
7
+ "testing"
8
+
9
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/api"
10
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/server"
11
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/shared/web"
12
+ )
13
+
14
+ // downloadStub counts DownloadSlides calls; every other operation is 501.
15
+ type downloadStub struct {
16
+ api.Unimplemented
17
+ calls *int
18
+ }
19
+
20
+ func (s downloadStub) DownloadSlides(w http.ResponseWriter, _ *http.Request, _ api.IdeaId) {
21
+ *s.calls++
22
+ w.Header().Set("Content-Type", "application/pdf")
23
+ _, _ = w.Write([]byte("%PDF-"))
24
+ }
25
+
26
+ func TestSlidesDownloadRoute(t *testing.T) {
27
+ const path = "/api/v1/ideas/7f0c1a52-2a51-4f0e-9a43-0d6b5f0b2a11/slides"
28
+ for _, tc := range []struct {
29
+ name, method, path, accept string
30
+ status int
31
+ code string
32
+ calls int
33
+ }{
34
+ // D47/D59: HEAD answers 405 and never runs the GET handler (which audits).
35
+ {"HEAD is 405", http.MethodHead, path, "", 405, "METHOD_NOT_ALLOWED", 0},
36
+ {"HEAD with a file Accept is still 405, not 406", http.MethodHead, path, "application/pdf", 405, "METHOD_NOT_ALLOWED", 0},
37
+ {"GET with a file Accept is served", http.MethodGet, path, "application/pdf", 200, "", 1},
38
+ {"GET with a browser Accept is served", http.MethodGet, path, "text/html,application/xhtml+xml", 200, "", 1},
39
+ {"GET with no Accept is served", http.MethodGet, path, "", 200, "", 1},
40
+ {"malformed ID is the uniform 404", http.MethodGet, "/api/v1/ideas/nope/slides", "", 404, "NOT_FOUND", 0},
41
+ {"other routes keep the 406", http.MethodGet, "/api/v1/ideas/7f0c1a52-2a51-4f0e-9a43-0d6b5f0b2a11", "application/pdf", 406, "NOT_ACCEPTABLE", 0},
42
+ {"wrong method on the download is 405", http.MethodPost, path, "", 405, "METHOD_NOT_ALLOWED", 0},
43
+ } {
44
+ t.Run(tc.name, func(t *testing.T) {
45
+ calls := 0
46
+ h := server.NewPublic(slog.New(slog.DiscardHandler), server.Mount(downloadStub{calls: &calls}))
47
+ req := httptest.NewRequest(tc.method, tc.path, nil)
48
+ if tc.accept != "" {
49
+ req.Header.Set("Accept", tc.accept)
50
+ }
51
+ rec := httptest.NewRecorder()
52
+ h.ServeHTTP(rec, req)
53
+ if rec.Code != tc.status || calls != tc.calls {
54
+ t.Fatalf("status %d calls %d, want %d %d (%s)", rec.Code, calls, tc.status, tc.calls, rec.Body)
55
+ }
56
+ if tc.code != "" {
57
+ if ct := rec.Header().Get("Content-Type"); ct != web.ProblemContentType {
58
+ t.Errorf("Content-Type %q", ct)
59
+ }
60
+ }
61
+ })
62
+ }
63
+ }
64
+
65
+ func TestIsFileDownload(t *testing.T) {
66
+ for _, tc := range []struct {
67
+ method, path string
68
+ want bool
69
+ }{
70
+ {http.MethodGet, "/api/v1/ideas/x/slides", true},
71
+ {http.MethodHead, "/api/v1/ideas/x/slides", true},
72
+ {http.MethodPut, "/api/v1/ideas/x/slides", false},
73
+ {http.MethodGet, "/api/v1/ideas//slides", false},
74
+ {http.MethodGet, "/api/v1/ideas/x/slides/", false},
75
+ {http.MethodGet, "/api/v1/ideas/x", false},
76
+ {http.MethodGet, "/api/v1/ideas/x/members/slides", false},
77
+ } {
78
+ if got := server.IsFileDownload(httptest.NewRequest(tc.method, tc.path, nil)); got != tc.want {
79
+ t.Errorf("%s %s = %v", tc.method, tc.path, got)
80
+ }
81
+ }
82
+ }
@@ -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
+ }