@nakedev/go-scaffold 0.4.0 → 0.5.0

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 (192) hide show
  1. package/README.md +598 -306
  2. package/dist/commands/auth.js +65 -23
  3. package/dist/commands/check.js +281 -0
  4. package/dist/commands/config.js +50 -0
  5. package/dist/commands/create.js +33 -2
  6. package/dist/commands/generate.js +29 -3
  7. package/dist/commands/method.js +74 -63
  8. package/dist/commands/migration.js +2 -2
  9. package/dist/commands/observability.js +4 -53
  10. package/dist/commands/rbac.js +21 -10
  11. package/dist/commands/undo.js +11 -3
  12. package/dist/commands/worker.js +15 -5
  13. package/dist/index.js +198 -59
  14. package/dist/prompts/auth-wizard.js +40 -6
  15. package/dist/prompts/create-wizard.js +42 -1
  16. package/dist/prompts/generate-wizard.js +89 -9
  17. package/dist/templates/auth-manifest.js +50 -19
  18. package/dist/templates/create-manifest.js +8 -0
  19. package/dist/templates/module-manifest.js +84 -26
  20. package/dist/templates/rbac-manifest.js +16 -11
  21. package/dist/templates/worker-manifest.js +4 -1
  22. package/dist/types.js +8 -0
  23. package/dist/utils/auth-patcher.js +124 -33
  24. package/dist/utils/config.js +167 -4
  25. package/dist/utils/docs-patcher.js +68 -0
  26. package/dist/utils/hexagonal-method-patcher.js +334 -0
  27. package/dist/utils/main-patcher.js +32 -30
  28. package/dist/utils/marker-patch.js +7 -1
  29. package/dist/utils/module-location.js +17 -11
  30. package/dist/utils/module-profile.js +32 -0
  31. package/dist/utils/platform-patcher.js +56 -7
  32. package/dist/utils/rbac-patcher.js +89 -210
  33. package/package.json +7 -2
  34. package/templates/add/auth/cmd/seed/main.go.hbs +15 -3
  35. package/templates/add/auth/docs/login.yaml.hbs +11 -1
  36. package/templates/add/auth/docs/mfa-verify.yaml.hbs +19 -0
  37. package/templates/add/auth/docs/provider-exchange.yaml.hbs +40 -0
  38. package/templates/add/auth/docs/provider-login.yaml.hbs +31 -0
  39. package/templates/add/auth/docs/refresh.yaml.hbs +7 -0
  40. package/templates/add/auth/docs/register.yaml.hbs +7 -0
  41. package/templates/add/auth/docs/reset-password.yaml.hbs +1 -1
  42. package/templates/add/auth/docs/schemas.yaml.hbs +59 -1
  43. package/templates/add/auth/docs/users-me-mfa-confirm.yaml.hbs +19 -0
  44. package/templates/add/auth/docs/users-me-mfa-disable.yaml.hbs +15 -0
  45. package/templates/add/auth/docs/users-me-mfa-setup.yaml.hbs +14 -0
  46. package/templates/add/auth/docs/users-me-mfa.yaml.hbs +12 -0
  47. package/templates/add/auth/internal/app/user/adapters/inbound/http/browser_policy.go.hbs +98 -0
  48. package/templates/add/auth/internal/app/user/adapters/inbound/http/dto.go.hbs +159 -0
  49. package/templates/add/auth/internal/app/user/adapters/inbound/http/handler.go.hbs +228 -0
  50. package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_local.go.hbs +76 -0
  51. package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_mfa.go.hbs +83 -0
  52. package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_oauth.go.hbs +70 -0
  53. package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_recovery.go.hbs +49 -0
  54. package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_test.go.hbs +311 -0
  55. package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_user.go.hbs +41 -0
  56. package/templates/add/auth/internal/app/user/adapters/inbound/http/session_cookie.go.hbs +35 -0
  57. package/templates/add/auth/internal/app/user/adapters/outbound/password/bcrypt.go.hbs +35 -0
  58. package/templates/add/auth/internal/app/user/adapters/outbound/password/bcrypt_test.go.hbs +20 -0
  59. package/templates/add/auth/internal/app/user/adapters/outbound/postgres/mfa_store.go.hbs +129 -0
  60. package/templates/add/auth/internal/app/user/adapters/outbound/postgres/mfa_store_test.go.hbs +174 -0
  61. package/templates/add/auth/internal/app/user/adapters/outbound/postgres/model.go.hbs +84 -0
  62. package/templates/add/auth/internal/app/user/adapters/outbound/postgres/repository.go.hbs +211 -0
  63. package/templates/add/auth/internal/app/user/{repository_test.go.hbs → adapters/outbound/postgres/repository_test.go.hbs} +18 -19
  64. package/templates/add/auth/internal/app/user/adapters/outbound/postgres/tokenstore_pg.go.hbs +213 -0
  65. package/templates/add/auth/internal/app/user/adapters/outbound/postgres/tokenstore_pg_test.go.hbs +103 -0
  66. package/templates/add/auth/internal/app/user/adapters/outbound/postgres/tokenstore_recovery.go.hbs +84 -0
  67. package/templates/add/auth/internal/app/user/adapters/outbound/redis/tokenstore.go.hbs +228 -0
  68. package/templates/add/auth/internal/app/user/adapters/outbound/redis/tokenstore_test.go.hbs +196 -0
  69. package/templates/add/auth/internal/app/user/application/contracts.go.hbs +52 -0
  70. package/templates/add/auth/internal/app/user/application/dto.go.hbs +75 -0
  71. package/templates/add/auth/internal/app/user/application/errors.go.hbs +62 -0
  72. package/templates/add/auth/internal/app/user/application/external_login.go.hbs +198 -0
  73. package/templates/add/auth/internal/app/user/application/jwt.go.hbs +58 -0
  74. package/templates/add/auth/internal/app/user/application/local_auth.go.hbs +96 -0
  75. package/templates/add/auth/internal/app/user/application/mfa_service.go.hbs +449 -0
  76. package/templates/add/auth/internal/app/user/application/mfa_service_test.go.hbs +200 -0
  77. package/templates/add/auth/internal/app/user/application/oauth.go.hbs +132 -0
  78. package/templates/add/auth/internal/app/user/application/provider_test.go.hbs +285 -0
  79. package/templates/add/auth/internal/app/user/application/recovery.go.hbs +82 -0
  80. package/templates/add/auth/internal/app/user/application/recovery_service.go.hbs +112 -0
  81. package/templates/add/auth/internal/app/user/application/service.go.hbs +145 -0
  82. package/templates/add/auth/internal/app/user/application/service_test.go.hbs +891 -0
  83. package/templates/add/auth/internal/app/user/application/sessions.go.hbs +99 -0
  84. package/templates/add/auth/internal/app/user/application/tokenstore_ports.go.hbs +14 -0
  85. package/templates/add/auth/internal/app/user/application/user_query.go.hbs +65 -0
  86. package/templates/add/auth/internal/app/user/composition.go.hbs +168 -0
  87. package/templates/add/auth/internal/app/user/domain/entity.go.hbs +41 -0
  88. package/templates/add/auth/internal/app/user/domain/errors.go.hbs +32 -0
  89. package/templates/add/auth/internal/app/user/ports/password.go.hbs +9 -0
  90. package/templates/add/auth/internal/app/user/ports/repository.go.hbs +90 -0
  91. package/templates/add/auth/internal/platform/authprovider/google/google.go.hbs +389 -0
  92. package/templates/add/auth/internal/platform/authprovider/google/google_test.go.hbs +312 -0
  93. package/templates/add/auth/migrations/create_auth_tokens.up.sql.hbs +10 -5
  94. package/templates/add/auth/migrations/create_identities.up.sql.hbs +1 -1
  95. package/templates/add/auth/migrations/create_login_throttle.up.sql.hbs +1 -1
  96. package/templates/add/auth/migrations/create_mfa.down.sql.hbs +3 -0
  97. package/templates/add/auth/migrations/create_mfa.up.sql.hbs +29 -0
  98. package/templates/add/auth/migrations/create_users.up.sql.hbs +4 -3
  99. package/templates/add/rbac/internal/app/role/adapters/inbound/http/handler.go.hbs +142 -0
  100. package/templates/add/rbac/internal/app/role/adapters/inbound/http/handler_test.go.hbs +19 -0
  101. package/templates/add/rbac/internal/app/role/adapters/outbound/postgres/model.go.hbs +48 -0
  102. package/templates/add/rbac/internal/app/role/adapters/outbound/postgres/repository.go.hbs +127 -0
  103. package/templates/add/rbac/internal/app/role/{repository_test.go.hbs → adapters/outbound/postgres/repository_test.go.hbs} +8 -8
  104. package/templates/add/rbac/internal/app/role/application/dto.go.hbs +47 -0
  105. package/templates/add/rbac/internal/app/role/application/errors.go.hbs +19 -0
  106. package/templates/add/rbac/internal/app/role/application/service.go.hbs +157 -0
  107. package/templates/add/rbac/internal/app/role/{service_test.go.hbs → application/service_test.go.hbs} +26 -19
  108. package/templates/add/rbac/internal/app/role/composition.go.hbs +48 -0
  109. package/templates/add/rbac/internal/app/role/domain/entity.go.hbs +23 -0
  110. package/templates/add/rbac/internal/app/role/domain/errors.go.hbs +26 -0
  111. package/templates/add/rbac/internal/app/role/ports/repository.go.hbs +25 -0
  112. package/templates/add/rbac/migrations/add_roles.down.sql.hbs +3 -11
  113. package/templates/add/rbac/migrations/add_roles.up.sql.hbs +17 -6
  114. package/templates/add/worker/internal/platform/queue/river_test.go.hbs +84 -0
  115. package/templates/create/base/.claude/skills/go-scaffold/SKILL.md.hbs +358 -121
  116. package/templates/create/base/.env.example.hbs +0 -1
  117. package/templates/create/base/.golangci.yml.hbs +2 -2
  118. package/templates/create/base/AGENTS.md.hbs +279 -67
  119. package/templates/create/base/Makefile.hbs +2 -1
  120. package/templates/create/base/README.md.hbs +115 -32
  121. package/templates/create/base/cmd/api/wiring.go.hbs +13 -9
  122. package/templates/create/base/internal/composition/doc.go.hbs +7 -0
  123. package/templates/create/base/internal/platform/database/database.go.hbs +3 -3
  124. package/templates/create/base/internal/shared/apperror/apperror.go.hbs +15 -2
  125. package/templates/create/base/internal/shared/config/config.go.hbs +0 -8
  126. package/templates/create/base/internal/shared/middleware/cors_test.go.hbs +40 -0
  127. package/templates/create/base/internal/shared/middleware/error.go.hbs +15 -5
  128. package/templates/create/features/docs/architecture.md.hbs +92 -32
  129. package/templates/create/features/docs/patterns.md.hbs +137 -91
  130. package/templates/create/features/docs/techstack.md.hbs +18 -3
  131. package/templates/generate/module/hexagonal/adapters/inbound/http/dto.go.hbs +45 -0
  132. package/templates/generate/module/hexagonal/adapters/inbound/http/dto.minimal.go.hbs +28 -0
  133. package/templates/generate/module/hexagonal/adapters/inbound/http/handler.go.hbs +182 -0
  134. package/templates/generate/module/hexagonal/adapters/inbound/http/handler.minimal.go.hbs +83 -0
  135. package/templates/generate/module/hexagonal/adapters/inbound/http/handler_crud_test.go.hbs +18 -0
  136. package/templates/generate/module/hexagonal/adapters/inbound/http/handler_test.go.hbs +30 -0
  137. package/templates/generate/module/hexagonal/adapters/outbound/postgres/model.go.hbs +37 -0
  138. package/templates/generate/module/hexagonal/adapters/outbound/postgres/repository.go.hbs +95 -0
  139. package/templates/generate/module/{repository_test.go.hbs → hexagonal/adapters/outbound/postgres/repository_test.go.hbs} +8 -8
  140. package/templates/generate/module/hexagonal/application/commands.crud.go.hbs +54 -0
  141. package/templates/generate/module/hexagonal/application/commands.go.hbs +25 -0
  142. package/templates/generate/module/hexagonal/application/cqrs_test.go.hbs +66 -0
  143. package/templates/generate/module/hexagonal/application/dto.go.hbs +35 -0
  144. package/templates/generate/module/hexagonal/application/dto.minimal.go.hbs +25 -0
  145. package/templates/generate/module/hexagonal/application/queries.crud.go.hbs +33 -0
  146. package/templates/generate/module/hexagonal/application/queries.go.hbs +25 -0
  147. package/templates/generate/module/hexagonal/application/service.crud.go.hbs +73 -0
  148. package/templates/generate/module/hexagonal/application/service.go.hbs +29 -0
  149. package/templates/generate/module/hexagonal/application/service_test.go.hbs +62 -0
  150. package/templates/generate/module/hexagonal/composition.go.hbs +27 -0
  151. package/templates/generate/module/hexagonal/domain/entity.go.hbs +20 -0
  152. package/templates/generate/module/hexagonal/domain/errors.go.hbs +11 -0
  153. package/templates/generate/module/hexagonal/ports/repository.go.hbs +38 -0
  154. package/templates/generate/module/migration.up.sql.hbs +1 -1
  155. package/dist/utils/method-patcher.js +0 -357
  156. package/templates/add/auth/docs/google-callback.yaml.hbs +0 -22
  157. package/templates/add/auth/docs/google-login.yaml.hbs +0 -7
  158. package/templates/add/auth/internal/app/user/dto.go.hbs +0 -77
  159. package/templates/add/auth/internal/app/user/errors.go.hbs +0 -43
  160. package/templates/add/auth/internal/app/user/handler.go.hbs +0 -276
  161. package/templates/add/auth/internal/app/user/jwt.go.hbs +0 -108
  162. package/templates/add/auth/internal/app/user/model/authtoken.go.hbs +0 -39
  163. package/templates/add/auth/internal/app/user/model/identity.go.hbs +0 -31
  164. package/templates/add/auth/internal/app/user/model/loginthrottle.go.hbs +0 -26
  165. package/templates/add/auth/internal/app/user/model/user.go.hbs +0 -30
  166. package/templates/add/auth/internal/app/user/repository.go.hbs +0 -137
  167. package/templates/add/auth/internal/app/user/service.go.hbs +0 -531
  168. package/templates/add/auth/internal/app/user/service_test.go.hbs +0 -316
  169. package/templates/add/auth/internal/app/user/tokenstore.go.hbs +0 -30
  170. package/templates/add/auth/internal/app/user/tokenstore_pg.go.hbs +0 -144
  171. package/templates/add/auth/internal/app/user/tokenstore_redis.go.hbs +0 -147
  172. package/templates/add/rbac/internal/app/role/dto.go.hbs +0 -45
  173. package/templates/add/rbac/internal/app/role/errors.go.hbs +0 -39
  174. package/templates/add/rbac/internal/app/role/handler.go.hbs +0 -104
  175. package/templates/add/rbac/internal/app/role/model/permission.go.hbs +0 -12
  176. package/templates/add/rbac/internal/app/role/model/role.go.hbs +0 -22
  177. package/templates/add/rbac/internal/app/role/model/role_permission.go.hbs +0 -11
  178. package/templates/add/rbac/internal/app/role/repository.go.hbs +0 -97
  179. package/templates/add/rbac/internal/app/role/service.go.hbs +0 -217
  180. package/templates/generate/module/dto.go.hbs +0 -36
  181. package/templates/generate/module/errors.go.hbs +0 -33
  182. package/templates/generate/module/handler.go.hbs +0 -134
  183. package/templates/generate/module/handler_test.go.hbs +0 -174
  184. package/templates/generate/module/minimal/dto.go.hbs +0 -28
  185. package/templates/generate/module/minimal/handler.go.hbs +0 -48
  186. package/templates/generate/module/minimal/handler_test.go.hbs +0 -10
  187. package/templates/generate/module/minimal/service.go.hbs +0 -45
  188. package/templates/generate/module/minimal/service_test.go.hbs +0 -77
  189. package/templates/generate/module/model/model.go.hbs +0 -36
  190. package/templates/generate/module/repository.go.hbs +0 -103
  191. package/templates/generate/module/service.go.hbs +0 -108
  192. package/templates/generate/module/service_test.go.hbs +0 -161
@@ -0,0 +1,891 @@
1
+ package application
2
+
3
+ import (
4
+ "context"
5
+ "errors"
6
+ "fmt"
7
+ "net/http"
8
+ "sync"
9
+ "testing"
10
+ "time"
11
+
12
+ "{{goModule}}/internal/app/user/domain"
13
+
14
+ "github.com/google/uuid"
15
+ )
16
+
17
+ // testTokenStore is the deliberately broad test-only composition used to
18
+ // populate the three independent token ports in Dependencies.
19
+ type testTokenStore interface {
20
+ RefreshTokenStore
21
+ OAuthTransactionStore
22
+ RecoveryTokenStore
23
+ }
24
+
25
+ // fakeTokenStore = in-memory mock of the token ports, mirroring the real store's
26
+ // three-map shape (active / used-tombstone / per-user session set) closely
27
+ // enough to exercise rotation + reuse-detection without a real Redis.
28
+ type fakeTokenStore struct {
29
+ mu sync.Mutex
30
+ txMu sync.Mutex
31
+ active map[string]RefreshTokenRecord
32
+ used map[string]uuid.UUID
33
+ sessions map[uuid.UUID]map[string]struct{}
34
+ transactions map[string]LoginTransaction
35
+ reset map[string]uuid.UUID
36
+ verify map[string]uuid.UUID
37
+ revokeAllErr error
38
+ }
39
+
40
+ func newFakeTokenStore() *fakeTokenStore {
41
+ return &fakeTokenStore{
42
+ active: map[string]RefreshTokenRecord{},
43
+ used: map[string]uuid.UUID{},
44
+ sessions: map[uuid.UUID]map[string]struct{}{},
45
+ transactions: map[string]LoginTransaction{},
46
+ reset: map[string]uuid.UUID{},
47
+ verify: map[string]uuid.UUID{},
48
+ }
49
+ }
50
+
51
+ func (f *fakeTokenStore) SetRefreshToken(_ context.Context, hash string, token RefreshTokenRecord) error {
52
+ f.mu.Lock()
53
+ defer f.mu.Unlock()
54
+ f.active[hash] = token
55
+ if f.sessions[token.UserID] == nil {
56
+ f.sessions[token.UserID] = map[string]struct{}{}
57
+ }
58
+ f.sessions[token.UserID][hash] = struct{}{}
59
+ return nil
60
+ }
61
+ func (f *fakeTokenStore) GetRefreshToken(_ context.Context, hash string) (uuid.UUID, bool, error) {
62
+ f.mu.Lock()
63
+ defer f.mu.Unlock()
64
+ token, ok := f.active[hash]
65
+ if !ok || !token.ExpiresAt.After(time.Now()) {
66
+ return uuid.Nil, false, nil
67
+ }
68
+ return token.UserID, true, nil
69
+ }
70
+
71
+ func (f *fakeTokenStore) ConsumeRefreshToken(_ context.Context, hash string) (RefreshTokenRecord, bool, error) {
72
+ f.mu.Lock()
73
+ defer f.mu.Unlock()
74
+ token, ok := f.active[hash]
75
+ if !ok || !token.ExpiresAt.After(time.Now()) {
76
+ return RefreshTokenRecord{}, false, nil
77
+ }
78
+ delete(f.active, hash)
79
+ delete(f.sessions[token.UserID], hash)
80
+ f.used[hash] = token.UserID
81
+ return token, true, nil
82
+ }
83
+
84
+ func (f *fakeTokenStore) DeleteRefreshToken(_ context.Context, hash string, userID uuid.UUID) error {
85
+ f.mu.Lock()
86
+ defer f.mu.Unlock()
87
+ delete(f.active, hash)
88
+ delete(f.sessions[userID], hash)
89
+ return nil
90
+ }
91
+ func (f *fakeTokenStore) RevokeAllRefreshTokens(_ context.Context, userID uuid.UUID) error {
92
+ f.mu.Lock()
93
+ defer f.mu.Unlock()
94
+ if f.revokeAllErr != nil {
95
+ err := f.revokeAllErr
96
+ f.revokeAllErr = nil
97
+ return err
98
+ }
99
+ for h := range f.sessions[userID] {
100
+ delete(f.active, h)
101
+ }
102
+ delete(f.sessions, userID)
103
+ return nil
104
+ }
105
+ func (f *fakeTokenStore) IsRefreshTokenUsed(_ context.Context, hash string) (uuid.UUID, bool, error) {
106
+ f.mu.Lock()
107
+ defer f.mu.Unlock()
108
+ id, ok := f.used[hash]
109
+ return id, ok, nil
110
+ }
111
+
112
+ func (f *fakeTokenStore) WithTransaction(ctx context.Context, fn func(context.Context) error) error {
113
+ // A real DB transaction serializes the single-token DELETE against another
114
+ // consumer. Keep the fake's rollback snapshot isolated the same way so a
115
+ // losing concurrent transaction cannot restore the winner's consume.
116
+ f.txMu.Lock()
117
+ defer f.txMu.Unlock()
118
+
119
+ f.mu.Lock()
120
+ active := cloneRefreshTokenMap(f.active)
121
+ used := cloneTokenMap(f.used)
122
+ transactions := cloneLoginTransactions(f.transactions)
123
+ reset := cloneTokenMap(f.reset)
124
+ verify := cloneTokenMap(f.verify)
125
+ sessions := cloneSessions(f.sessions)
126
+ f.mu.Unlock()
127
+
128
+ err := fn(ctx)
129
+ if err == nil {
130
+ return nil
131
+ }
132
+ f.mu.Lock()
133
+ f.active, f.used, f.transactions, f.reset, f.verify, f.sessions = active, used, transactions, reset, verify, sessions
134
+ f.mu.Unlock()
135
+ return err
136
+ }
137
+
138
+ func cloneRefreshTokenMap(in map[string]RefreshTokenRecord) map[string]RefreshTokenRecord {
139
+ out := make(map[string]RefreshTokenRecord, len(in))
140
+ for key, value := range in {
141
+ out[key] = value
142
+ }
143
+ return out
144
+ }
145
+
146
+ func cloneTokenMap(in map[string]uuid.UUID) map[string]uuid.UUID {
147
+ out := make(map[string]uuid.UUID, len(in))
148
+ for key, value := range in {
149
+ out[key] = value
150
+ }
151
+ return out
152
+ }
153
+
154
+ func cloneLoginTransactions(in map[string]LoginTransaction) map[string]LoginTransaction {
155
+ out := make(map[string]LoginTransaction, len(in))
156
+ for key, value := range in {
157
+ out[key] = value
158
+ }
159
+ return out
160
+ }
161
+
162
+ func (f *fakeTokenStore) SetLoginTransaction(_ context.Context, hash string, transaction LoginTransaction) error {
163
+ f.mu.Lock()
164
+ defer f.mu.Unlock()
165
+ f.transactions[hash] = transaction
166
+ return nil
167
+ }
168
+
169
+ func (f *fakeTokenStore) ConsumeLoginTransaction(_ context.Context, hash string) (LoginTransaction, bool, error) {
170
+ f.mu.Lock()
171
+ defer f.mu.Unlock()
172
+ transaction, ok := f.transactions[hash]
173
+ if !ok || !transaction.ExpiresAt.After(time.Now()) {
174
+ return LoginTransaction{}, false, nil
175
+ }
176
+ delete(f.transactions, hash)
177
+ return transaction, true, nil
178
+ }
179
+
180
+ func cloneSessions(in map[uuid.UUID]map[string]struct{}) map[uuid.UUID]map[string]struct{} {
181
+ out := make(map[uuid.UUID]map[string]struct{}, len(in))
182
+ for userID, hashes := range in {
183
+ out[userID] = map[string]struct{}{}
184
+ for hash := range hashes {
185
+ out[userID][hash] = struct{}{}
186
+ }
187
+ }
188
+ return out
189
+ }
190
+
191
+ func (f *fakeTokenStore) SetPasswordResetToken(_ context.Context, hash string, userID uuid.UUID, _ time.Duration) error {
192
+ f.mu.Lock()
193
+ defer f.mu.Unlock()
194
+ f.reset[hash] = userID
195
+ return nil
196
+ }
197
+ func (f *fakeTokenStore) ConsumePasswordResetToken(_ context.Context, hash string) (uuid.UUID, bool, error) {
198
+ f.mu.Lock()
199
+ defer f.mu.Unlock()
200
+ id, ok := f.reset[hash]
201
+ if ok {
202
+ delete(f.reset, hash)
203
+ }
204
+ return id, ok, nil
205
+ }
206
+ func (f *fakeTokenStore) SetEmailVerifyToken(_ context.Context, hash string, userID uuid.UUID, _ time.Duration) error {
207
+ f.mu.Lock()
208
+ defer f.mu.Unlock()
209
+ f.verify[hash] = userID
210
+ return nil
211
+ }
212
+ func (f *fakeTokenStore) ConsumeEmailVerifyToken(_ context.Context, hash string) (uuid.UUID, bool, error) {
213
+ f.mu.Lock()
214
+ defer f.mu.Unlock()
215
+ id, ok := f.verify[hash]
216
+ if ok {
217
+ delete(f.verify, hash)
218
+ }
219
+ return id, ok, nil
220
+ }
221
+
222
+ type fakeMFAStore struct {
223
+ mu sync.Mutex
224
+ enrollments map[uuid.UUID]MFAEnrollment
225
+ challenges map[string]MFAChallenge
226
+ recovery map[uuid.UUID]map[string]struct{}
227
+ }
228
+
229
+ func newFakeMFAStore() *fakeMFAStore {
230
+ return &fakeMFAStore{
231
+ enrollments: map[uuid.UUID]MFAEnrollment{},
232
+ challenges: map[string]MFAChallenge{},
233
+ recovery: map[uuid.UUID]map[string]struct{}{},
234
+ }
235
+ }
236
+
237
+ func (f *fakeMFAStore) GetEnrollment(_ context.Context, userID uuid.UUID) (MFAEnrollment, bool, error) {
238
+ f.mu.Lock()
239
+ defer f.mu.Unlock()
240
+ enrollment, ok := f.enrollments[userID]
241
+ return enrollment, ok, nil
242
+ }
243
+
244
+ func (f *fakeMFAStore) PutPendingEnrollment(_ context.Context, userID uuid.UUID, secret string) error {
245
+ f.mu.Lock()
246
+ defer f.mu.Unlock()
247
+ if current, ok := f.enrollments[userID]; ok && current.Enabled {
248
+ return errors.New("MFA already enabled")
249
+ }
250
+ f.enrollments[userID] = MFAEnrollment{EncryptedSecret: secret}
251
+ return nil
252
+ }
253
+
254
+ func (f *fakeMFAStore) ConfirmEnrollment(_ context.Context, userID uuid.UUID, secret string, hashes []string) error {
255
+ f.mu.Lock()
256
+ defer f.mu.Unlock()
257
+ if len(hashes) == 0 {
258
+ return errors.New("MFA recovery codes are required")
259
+ }
260
+ f.enrollments[userID] = MFAEnrollment{EncryptedSecret: secret, Enabled: true}
261
+ f.recovery[userID] = map[string]struct{}{}
262
+ for _, hash := range hashes {
263
+ f.recovery[userID][hash] = struct{}{}
264
+ }
265
+ return nil
266
+ }
267
+
268
+ func (f *fakeMFAStore) Disable(_ context.Context, userID uuid.UUID) error {
269
+ f.mu.Lock()
270
+ defer f.mu.Unlock()
271
+ delete(f.enrollments, userID)
272
+ delete(f.recovery, userID)
273
+ return nil
274
+ }
275
+
276
+ func (f *fakeMFAStore) CreateChallenge(_ context.Context, hash string, challenge MFAChallenge) error {
277
+ f.mu.Lock()
278
+ defer f.mu.Unlock()
279
+ f.challenges[hash] = challenge
280
+ return nil
281
+ }
282
+
283
+ func (f *fakeMFAStore) ConsumeChallenge(_ context.Context, hash string) (MFAChallenge, bool, error) {
284
+ f.mu.Lock()
285
+ defer f.mu.Unlock()
286
+ challenge, ok := f.challenges[hash]
287
+ if !ok || !challenge.ExpiresAt.After(time.Now()) {
288
+ return MFAChallenge{}, false, nil
289
+ }
290
+ delete(f.challenges, hash)
291
+ return challenge, true, nil
292
+ }
293
+
294
+ func (f *fakeMFAStore) ConsumeRecoveryCode(_ context.Context, userID uuid.UUID, hash string) (bool, error) {
295
+ f.mu.Lock()
296
+ defer f.mu.Unlock()
297
+ codes := f.recovery[userID]
298
+ if _, ok := codes[hash]; !ok {
299
+ return false, nil
300
+ }
301
+ delete(codes, hash)
302
+ return true, nil
303
+ }
304
+
305
+ // fakeRepo = mock of the repository interface — only FindByID is exercised
306
+ // by the tests below (Refresh looks the user up after validating the
307
+ // token), the rest just satisfy the interface.
308
+ type fakeRepo struct {
309
+ user *domain.User
310
+ identity *domain.Identity
311
+ updateIdentityErr error
312
+ updateUserErr error
313
+ failures map[string]int
314
+ lockedUntil map[string]time.Time
315
+ // go-scaffold:repository-stub-fields
316
+ }
317
+
318
+ // throttle: the fake keeps the counter in memory so the lockout path can be
319
+ // exercised without a database. Same shape as the real one — a key, a count,
320
+ // and a time — because what the tests care about is when Service decides to
321
+ // stop asking bcrypt anything.
322
+ func (f *fakeRepo) LoginLockedUntil(_ context.Context, key string) (time.Time, error) {
323
+ return f.lockedUntil[key], nil
324
+ }
325
+
326
+ func (f *fakeRepo) RecordLoginFailure(_ context.Context, key string, freeAttempts int, maxLock time.Duration) error {
327
+ if f.lockedUntil == nil {
328
+ f.lockedUntil = map[string]time.Time{}
329
+ }
330
+ f.failures[key]++
331
+ if f.failures[key] > freeAttempts {
332
+ lock := time.Duration(1<<uint(f.failures[key]-freeAttempts-1)) * time.Second
333
+ if lock > maxLock {
334
+ lock = maxLock
335
+ }
336
+ f.lockedUntil[key] = time.Now().Add(lock)
337
+ }
338
+ return nil
339
+ }
340
+
341
+ func (f *fakeRepo) ClearLoginFailures(_ context.Context, key string) error {
342
+ delete(f.failures, key)
343
+ delete(f.lockedUntil, key)
344
+ return nil
345
+ }
346
+
347
+ func (f *fakeRepo) FindByEmail(context.Context, string) (*domain.User, error) {
348
+ return nil, domain.ErrNotFound
349
+ }
350
+ func (f *fakeRepo) FindByID(_ context.Context, id uuid.UUID) (*domain.User, error) {
351
+ if f.user != nil && f.user.ID == id {
352
+ return f.user, nil
353
+ }
354
+ return nil, domain.ErrNotFound
355
+ }
356
+ func (f *fakeRepo) UpdateUser(_ context.Context, u *domain.User) error {
357
+ if f.updateUserErr != nil {
358
+ return f.updateUserErr
359
+ }
360
+ f.user = u
361
+ return nil
362
+ }
363
+ func (f *fakeRepo) FindAll(context.Context, int, int) ([]domain.User, error) { return nil, nil }
364
+ func (f *fakeRepo) FindIdentity(_ context.Context, userID uuid.UUID, _ domain.Provider) (*domain.Identity, error) {
365
+ if f.identity != nil && f.identity.UserID == userID {
366
+ copy := *f.identity
367
+ return &copy, nil
368
+ }
369
+ return nil, domain.ErrNotFound
370
+ }
371
+ func (f *fakeRepo) FindIdentityByProviderUID(context.Context, domain.Provider, string) (*domain.Identity, error) {
372
+ return nil, domain.ErrNotFound
373
+ }
374
+ func (f *fakeRepo) CreateUserWithIdentity(context.Context, *domain.User, *domain.Identity) error {
375
+ return nil
376
+ }
377
+ func (f *fakeRepo) CreateIdentity(context.Context, *domain.Identity) error { return nil }
378
+ func (f *fakeRepo) UpdateIdentity(_ context.Context, i *domain.Identity) error {
379
+ if f.updateIdentityErr != nil {
380
+ return f.updateIdentityErr
381
+ }
382
+ f.identity = i
383
+ return nil
384
+ }
385
+
386
+ // go-scaffold:user-fake-repo-methods
387
+ // go-scaffold:repository-stub-methods
388
+
389
+ type fakeMailer struct{}
390
+
391
+ func (fakeMailer) Send(context.Context, string, string, string) error { return nil }
392
+
393
+ type fakePasswordHasher struct{}
394
+
395
+ func (fakePasswordHasher) Hash(password string) (string, error) { return "hashed:" + password, nil }
396
+
397
+ func (fakePasswordHasher) Compare(hash, password string) error {
398
+ if hash != "hashed:"+password {
399
+ return errors.New("password does not match")
400
+ }
401
+ return nil
402
+ }
403
+
404
+ // fakeRoles satisfies role's roleChecker interface structurally.
405
+ type fakeRoles struct{}
406
+
407
+ func (fakeRoles) CodeExists(context.Context, string) (bool, error) { return true, nil }
408
+
409
+ // go-scaffold:user-service-test-types
410
+
411
+ // newTestService is the one NewService call site every test below shares —
412
+ // keeping it singular means `add rbac`'s marker patch only has one place to
413
+ // add the roleChecker arg it introduces (patching every call site
414
+ // individually would need a marker-per-callsite, easy to miss one).
415
+ func newTestService(repo repository, tokens testTokenStore) *Service {
416
+ return NewService(Dependencies{
417
+ Repository: repo,
418
+ Passwords: fakePasswordHasher{},
419
+ RefreshTokens: tokens,
420
+ OAuthTransactions: tokens,
421
+ RecoveryTokens: tokens,
422
+ MFA: newFakeMFAStore(),
423
+ Mailer: fakeMailer{},
424
+ Providers: NewProviderRegistry(),
425
+ Roles: fakeRoles{},
426
+ // go-scaffold:user-service-test-deps
427
+ }, AuthConfig{
428
+ JWTSecret: "test-secret",
429
+ JWTAccessTTL: time.Minute,
430
+ JWTRefreshTTL: time.Hour,
431
+ JWTRefreshMaxTTL: 24 * time.Hour,
432
+ OAuthStateTTL: 10 * time.Minute,
433
+ })
434
+ }
435
+
436
+ func seedRefreshToken(ctx context.Context, tokens *fakeTokenStore, raw string, userID uuid.UUID, ttl time.Duration) error {
437
+ now := time.Now()
438
+ return tokens.SetRefreshToken(ctx, hashToken(raw), RefreshTokenRecord{
439
+ UserID: userID,
440
+ ExpiresAt: now.Add(ttl),
441
+ AbsoluteExpiresAt: now.Add(24 * time.Hour),
442
+ })
443
+ }
444
+
445
+ func status(t *testing.T, err error) int {
446
+ t.Helper()
447
+ var ruleErr *domain.RuleError
448
+ if errors.As(err, &ruleErr) {
449
+ switch ruleErr.Code {
450
+ case "AUTH_INVALID_TOKEN", "AUTH_INVALID_CREDENTIALS":
451
+ return http.StatusUnauthorized
452
+ case "AUTH_TOO_MANY_ATTEMPTS":
453
+ return http.StatusTooManyRequests
454
+ }
455
+ }
456
+ return http.StatusInternalServerError
457
+ }
458
+
459
+ func TestService_Refresh_RotatesTheToken(t *testing.T) {
460
+ ctx := context.Background()
461
+ userID := uuid.New()
462
+ tokens := newFakeTokenStore()
463
+ rawOld := "old-refresh-token"
464
+ if err := seedRefreshToken(ctx, tokens, rawOld, userID, time.Hour); err != nil {
465
+ t.Fatalf("seed: %v", err)
466
+ }
467
+ svc := newTestService(&fakeRepo{user: &domain.User{ID: userID, Email: "a@example.com"}}, tokens)
468
+
469
+ auth, err := svc.Refresh(ctx, rawOld)
470
+ if err != nil {
471
+ t.Fatalf("unexpected error: %v", err)
472
+ }
473
+ if auth.RefreshToken == "" || auth.RefreshToken == rawOld {
474
+ t.Fatalf("expected a new, different refresh token, got %q", auth.RefreshToken)
475
+ }
476
+ if _, ok, _ := tokens.GetRefreshToken(ctx, hashToken(rawOld)); ok {
477
+ t.Fatal("expected the old refresh token to be consumed (no longer active) after rotation")
478
+ }
479
+ if _, ok, _ := tokens.GetRefreshToken(ctx, hashToken(auth.RefreshToken)); !ok {
480
+ t.Fatal("expected the newly rotated refresh token to be active")
481
+ }
482
+ }
483
+
484
+ func TestService_Refresh_ConcurrentPresentationHasOneWinner(t *testing.T) {
485
+ ctx := context.Background()
486
+ userID := uuid.New()
487
+ tokens := newFakeTokenStore()
488
+ raw := "one-refresh-token"
489
+ if err := seedRefreshToken(ctx, tokens, raw, userID, time.Hour); err != nil {
490
+ t.Fatalf("seed: %v", err)
491
+ }
492
+ svc := newTestService(&fakeRepo{user: &domain.User{ID: userID, Email: "a@example.com"}}, tokens)
493
+
494
+ const callers = 32
495
+ start := make(chan struct{})
496
+ results := make(chan error, callers)
497
+ for i := 0; i < callers; i++ {
498
+ go func() {
499
+ <-start
500
+ _, err := svc.Refresh(ctx, raw)
501
+ results <- err
502
+ }()
503
+ }
504
+ close(start)
505
+
506
+ winners := 0
507
+ for i := 0; i < callers; i++ {
508
+ if err := <-results; err == nil {
509
+ winners++
510
+ } else if code(err) != "AUTH_INVALID_TOKEN" {
511
+ t.Fatalf("unexpected loser error: %v", err)
512
+ }
513
+ }
514
+ if winners != 1 {
515
+ t.Fatalf("expected exactly one refresh winner, got %d", winners)
516
+ }
517
+ }
518
+
519
+ func TestService_Refresh_ReplayingARotatedOutTokenRevokesEverySession(t *testing.T) {
520
+ ctx := context.Background()
521
+ userID := uuid.New()
522
+ tokens := newFakeTokenStore()
523
+ rawOld := "old-refresh-token"
524
+ rawOtherSession := "another-devices-refresh-token"
525
+ if err := seedRefreshToken(ctx, tokens, rawOld, userID, time.Hour); err != nil {
526
+ t.Fatalf("seed: %v", err)
527
+ }
528
+ if err := seedRefreshToken(ctx, tokens, rawOtherSession, userID, time.Hour); err != nil {
529
+ t.Fatalf("seed: %v", err)
530
+ }
531
+ svc := newTestService(&fakeRepo{user: &domain.User{ID: userID, Email: "a@example.com"}}, tokens)
532
+
533
+ // rotate rawOld once — legitimate use, consumes + tombstones it
534
+ if _, err := svc.Refresh(ctx, rawOld); err != nil {
535
+ t.Fatalf("first refresh: unexpected error: %v", err)
536
+ }
537
+
538
+ // replay the now-rotated-out raw value — this is what happens if it leaked
539
+ _, err := svc.Refresh(ctx, rawOld)
540
+ if got := status(t, err); got != http.StatusUnauthorized {
541
+ t.Fatalf("want 401 replaying a rotated-out token, got %d", got)
542
+ }
543
+ if _, ok, _ := tokens.GetRefreshToken(ctx, hashToken(rawOtherSession)); ok {
544
+ t.Fatal("expected reuse of a rotated-out token to revoke every session for that user, but another session's token is still active")
545
+ }
546
+ }
547
+
548
+ func TestService_Refresh_DoesNotExtendTheAbsoluteLifetime(t *testing.T) {
549
+ ctx := context.Background()
550
+ userID := uuid.New()
551
+ tokens := newFakeTokenStore()
552
+ raw := "bounded-refresh-token"
553
+ issuedAt := time.Now()
554
+ absoluteExpiry := issuedAt.Add(2 * time.Hour)
555
+ if err := tokens.SetRefreshToken(ctx, hashToken(raw), RefreshTokenRecord{
556
+ UserID: userID,
557
+ ExpiresAt: issuedAt.Add(time.Hour),
558
+ AbsoluteExpiresAt: absoluteExpiry,
559
+ }); err != nil {
560
+ t.Fatalf("seed: %v", err)
561
+ }
562
+ svc := newTestService(&fakeRepo{user: &domain.User{ID: userID}}, tokens)
563
+ rotationTime := issuedAt.Add(90 * time.Minute)
564
+ svc.now = func() time.Time { return rotationTime }
565
+
566
+ auth, err := svc.Refresh(ctx, raw)
567
+ if err != nil {
568
+ t.Fatalf("refresh: %v", err)
569
+ }
570
+ tokens.mu.Lock()
571
+ rotated := tokens.active[hashToken(auth.RefreshToken)]
572
+ tokens.mu.Unlock()
573
+ if !rotated.AbsoluteExpiresAt.Equal(absoluteExpiry) {
574
+ t.Fatalf("absolute expiry moved during rotation: got %s want %s", rotated.AbsoluteExpiresAt, absoluteExpiry)
575
+ }
576
+ if !rotated.ExpiresAt.Equal(absoluteExpiry) {
577
+ t.Fatalf("rotated inactivity expiry was not capped at the absolute expiry: got %s want %s", rotated.ExpiresAt, absoluteExpiry)
578
+ }
579
+ }
580
+
581
+ func TestService_Refresh_ReuseRevokeFailureFailsClosed(t *testing.T) {
582
+ ctx := context.Background()
583
+ userID := uuid.New()
584
+ tokens := newFakeTokenStore()
585
+ rawOld := "replayed-refresh-token"
586
+ rawOther := "still-active-refresh-token"
587
+ if err := seedRefreshToken(ctx, tokens, rawOld, userID, time.Hour); err != nil {
588
+ t.Fatalf("seed old token: %v", err)
589
+ }
590
+ if err := seedRefreshToken(ctx, tokens, rawOther, userID, time.Hour); err != nil {
591
+ t.Fatalf("seed other token: %v", err)
592
+ }
593
+ svc := newTestService(&fakeRepo{user: &domain.User{ID: userID}}, tokens)
594
+ if _, err := svc.Refresh(ctx, rawOld); err != nil {
595
+ t.Fatalf("initial rotation: %v", err)
596
+ }
597
+ tokens.revokeAllErr = errors.New("session store unavailable")
598
+ if _, err := svc.Refresh(ctx, rawOld); status(t, err) != http.StatusInternalServerError {
599
+ t.Fatalf("reuse with failed revoke must fail closed with 500, got %v", err)
600
+ }
601
+ if _, ok, _ := tokens.GetRefreshToken(ctx, hashToken(rawOther)); !ok {
602
+ t.Fatal("failed revoke must not be reported as a completed session revocation")
603
+ }
604
+ }
605
+
606
+ func TestService_Refresh_UnknownTokenIsRejectedWithoutRevoking(t *testing.T) {
607
+ ctx := context.Background()
608
+ userID := uuid.New()
609
+ tokens := newFakeTokenStore()
610
+ rawLegit := "a-legit-session-token"
611
+ if err := seedRefreshToken(ctx, tokens, rawLegit, userID, time.Hour); err != nil {
612
+ t.Fatalf("seed: %v", err)
613
+ }
614
+ svc := newTestService(&fakeRepo{user: &domain.User{ID: userID}}, tokens)
615
+
616
+ _, err := svc.Refresh(ctx, "never-issued-token")
617
+ if got := status(t, err); got != http.StatusUnauthorized {
618
+ t.Fatalf("want 401 for an unrecognized token, got %d", got)
619
+ }
620
+ if _, ok, _ := tokens.GetRefreshToken(ctx, hashToken(rawLegit)); !ok {
621
+ t.Fatal("a never-issued token shouldn't revoke unrelated sessions")
622
+ }
623
+ }
624
+
625
+ func TestService_LogoutAll_RevokesEverySessionButLeavesOthersAlone(t *testing.T) {
626
+ ctx := context.Background()
627
+ userID := uuid.New()
628
+ otherUserID := uuid.New()
629
+ tokens := newFakeTokenStore()
630
+ rawA := "user-session-a"
631
+ rawB := "user-session-b"
632
+ rawOther := "other-users-session"
633
+ for raw, id := range map[string]uuid.UUID{rawA: userID, rawB: userID, rawOther: otherUserID} {
634
+ if err := seedRefreshToken(ctx, tokens, raw, id, time.Hour); err != nil {
635
+ t.Fatalf("seed: %v", err)
636
+ }
637
+ }
638
+ svc := newTestService(&fakeRepo{user: &domain.User{ID: userID}}, tokens)
639
+
640
+ if err := svc.LogoutAll(ctx, userID); err != nil {
641
+ t.Fatalf("unexpected error: %v", err)
642
+ }
643
+ if _, ok, _ := tokens.GetRefreshToken(ctx, hashToken(rawA)); ok {
644
+ t.Fatal("expected session A to be revoked")
645
+ }
646
+ if _, ok, _ := tokens.GetRefreshToken(ctx, hashToken(rawB)); ok {
647
+ t.Fatal("expected session B to be revoked")
648
+ }
649
+ if _, ok, _ := tokens.GetRefreshToken(ctx, hashToken(rawOther)); !ok {
650
+ t.Fatal("expected a different user's session to be untouched by LogoutAll")
651
+ }
652
+ }
653
+
654
+ func TestService_ResetPassword_DBFailureAllowsRetry(t *testing.T) {
655
+ ctx := context.Background()
656
+ userID := uuid.New()
657
+ passwordHash := "old-hash"
658
+ repo := &fakeRepo{
659
+ user: &domain.User{ID: userID},
660
+ identity: &domain.Identity{ID: uuid.New(), UserID: userID, Provider: domain.ProviderLocal, PasswordHash: &passwordHash},
661
+ updateIdentityErr: errors.New("database unavailable"),
662
+ }
663
+ tokens := newFakeTokenStore()
664
+ raw := "reset-token"
665
+ if err := tokens.SetPasswordResetToken(ctx, hashToken(raw), userID, time.Hour); err != nil {
666
+ t.Fatalf("seed: %v", err)
667
+ }
668
+ svc := newTestService(repo, tokens)
669
+
670
+ if err := svc.ResetPassword(ctx, raw, "new-password"); status(t, err) != http.StatusInternalServerError {
671
+ t.Fatalf("expected a 500 on the simulated DB failure, got %v", err)
672
+ }
673
+ repo.updateIdentityErr = nil
674
+ if err := svc.ResetPassword(ctx, raw, "new-password"); err != nil {
675
+ t.Fatalf("retry should succeed after the transaction rollback: %v", err)
676
+ }
677
+ }
678
+
679
+ func TestService_ResetPassword_SessionRevokeFailureRestoresTokenForRetry(t *testing.T) {
680
+ ctx := context.Background()
681
+ userID := uuid.New()
682
+ passwordHash := "old-hash"
683
+ repo := &fakeRepo{
684
+ user: &domain.User{ID: userID},
685
+ identity: &domain.Identity{ID: uuid.New(), UserID: userID, Provider: domain.ProviderLocal, PasswordHash: &passwordHash},
686
+ }
687
+ tokens := newFakeTokenStore()
688
+ raw := "reset-token-session-store-outage"
689
+ if err := tokens.SetPasswordResetToken(ctx, hashToken(raw), userID, time.Hour); err != nil {
690
+ t.Fatalf("seed: %v", err)
691
+ }
692
+ tokens.revokeAllErr = errors.New("redis unavailable")
693
+ svc := newTestService(repo, tokens)
694
+
695
+ if err := svc.ResetPassword(ctx, raw, "new-password"); status(t, err) != http.StatusInternalServerError {
696
+ t.Fatalf("expected a 500 while the session store is unavailable, got %v", err)
697
+ }
698
+ if _, ok := tokens.reset[hashToken(raw)]; !ok {
699
+ t.Fatal("expected the reset token to be restored so the caller can retry")
700
+ }
701
+ if err := svc.ResetPassword(ctx, raw, "new-password"); err != nil {
702
+ t.Fatalf("retry should complete the password reset and session revoke: %v", err)
703
+ }
704
+ if _, ok := tokens.reset[hashToken(raw)]; ok {
705
+ t.Fatal("expected the reset token to be consumed after the retry succeeds")
706
+ }
707
+ }
708
+
709
+ func TestService_VerifyEmail_DBFailureAllowsRetry(t *testing.T) {
710
+ ctx := context.Background()
711
+ userID := uuid.New()
712
+ repo := &fakeRepo{
713
+ user: &domain.User{ID: userID},
714
+ updateUserErr: errors.New("database unavailable"),
715
+ }
716
+ tokens := newFakeTokenStore()
717
+ raw := "verify-token"
718
+ if err := tokens.SetEmailVerifyToken(ctx, hashToken(raw), userID, time.Hour); err != nil {
719
+ t.Fatalf("seed: %v", err)
720
+ }
721
+ svc := newTestService(repo, tokens)
722
+
723
+ if err := svc.VerifyEmail(ctx, raw); status(t, err) != http.StatusInternalServerError {
724
+ t.Fatalf("expected a 500 on the simulated DB failure, got %v", err)
725
+ }
726
+ repo.updateUserErr = nil
727
+ if err := svc.VerifyEmail(ctx, raw); err != nil {
728
+ t.Fatalf("retry should succeed after the transaction rollback: %v", err)
729
+ }
730
+ }
731
+
732
+ func TestService_ResetPassword_DuplicateTokenIsRejected(t *testing.T) {
733
+ ctx := context.Background()
734
+ userID := uuid.New()
735
+ passwordHash := "old-hash"
736
+ repo := &fakeRepo{
737
+ user: &domain.User{ID: userID},
738
+ identity: &domain.Identity{ID: uuid.New(), UserID: userID, Provider: domain.ProviderLocal, PasswordHash: &passwordHash},
739
+ }
740
+ tokens := newFakeTokenStore()
741
+ raw := "one-time-reset-token"
742
+ if err := tokens.SetPasswordResetToken(ctx, hashToken(raw), userID, time.Hour); err != nil {
743
+ t.Fatalf("seed: %v", err)
744
+ }
745
+ svc := newTestService(repo, tokens)
746
+ if err := svc.ResetPassword(ctx, raw, "new-password"); err != nil {
747
+ t.Fatalf("first use: %v", err)
748
+ }
749
+ if err := svc.ResetPassword(ctx, raw, "another-password"); code(err) != "AUTH_INVALID_TOKEN" {
750
+ t.Fatalf("second use should be rejected as invalid, got %v", err)
751
+ }
752
+ }
753
+
754
+ func TestService_ResetPassword_ConcurrentPresentationHasOneWinner(t *testing.T) {
755
+ ctx := context.Background()
756
+ userID := uuid.New()
757
+ passwordHash := "old-hash"
758
+ repo := &fakeRepo{
759
+ user: &domain.User{ID: userID},
760
+ identity: &domain.Identity{ID: uuid.New(), UserID: userID, Provider: domain.ProviderLocal, PasswordHash: &passwordHash},
761
+ }
762
+ tokens := newFakeTokenStore()
763
+ raw := "concurrent-reset-token"
764
+ if err := tokens.SetPasswordResetToken(ctx, hashToken(raw), userID, time.Hour); err != nil {
765
+ t.Fatalf("seed: %v", err)
766
+ }
767
+ svc := newTestService(repo, tokens)
768
+
769
+ const callers = 8
770
+ start := make(chan struct{})
771
+ results := make(chan error, callers)
772
+ for i := 0; i < callers; i++ {
773
+ go func() {
774
+ <-start
775
+ results <- svc.ResetPassword(ctx, raw, "new-password")
776
+ }()
777
+ }
778
+ close(start)
779
+
780
+ winners := 0
781
+ for i := 0; i < callers; i++ {
782
+ if err := <-results; err == nil {
783
+ winners++
784
+ } else if code(err) != "AUTH_INVALID_TOKEN" {
785
+ t.Fatalf("unexpected loser error: %v", err)
786
+ }
787
+ }
788
+ if winners != 1 {
789
+ t.Fatalf("expected exactly one reset winner, got %d", winners)
790
+ }
791
+ }
792
+
793
+ func TestService_VerifyEmail_DuplicateTokenIsRejected(t *testing.T) {
794
+ ctx := context.Background()
795
+ userID := uuid.New()
796
+ repo := &fakeRepo{user: &domain.User{ID: userID}}
797
+ tokens := newFakeTokenStore()
798
+ raw := "one-time-verification-token"
799
+ if err := tokens.SetEmailVerifyToken(ctx, hashToken(raw), userID, time.Hour); err != nil {
800
+ t.Fatalf("seed: %v", err)
801
+ }
802
+ svc := newTestService(repo, tokens)
803
+ if err := svc.VerifyEmail(ctx, raw); err != nil {
804
+ t.Fatalf("first use: %v", err)
805
+ }
806
+ if err := svc.VerifyEmail(ctx, raw); code(err) != "AUTH_INVALID_TOKEN" {
807
+ t.Fatalf("second use should be rejected as invalid, got %v", err)
808
+ }
809
+ }
810
+
811
+ func TestService_VerifyEmail_ConcurrentPresentationHasOneWinner(t *testing.T) {
812
+ ctx := context.Background()
813
+ userID := uuid.New()
814
+ repo := &fakeRepo{user: &domain.User{ID: userID}}
815
+ tokens := newFakeTokenStore()
816
+ raw := "concurrent-verification-token"
817
+ if err := tokens.SetEmailVerifyToken(ctx, hashToken(raw), userID, time.Hour); err != nil {
818
+ t.Fatalf("seed: %v", err)
819
+ }
820
+ svc := newTestService(repo, tokens)
821
+
822
+ const callers = 32
823
+ start := make(chan struct{})
824
+ results := make(chan error, callers)
825
+ for i := 0; i < callers; i++ {
826
+ go func() {
827
+ <-start
828
+ results <- svc.VerifyEmail(ctx, raw)
829
+ }()
830
+ }
831
+ close(start)
832
+
833
+ winners := 0
834
+ for i := 0; i < callers; i++ {
835
+ if err := <-results; err == nil {
836
+ winners++
837
+ } else if code(err) != "AUTH_INVALID_TOKEN" {
838
+ t.Fatalf("unexpected loser error: %v", err)
839
+ }
840
+ }
841
+ if winners != 1 {
842
+ t.Fatalf("expected exactly one verification winner, got %d", winners)
843
+ }
844
+ }
845
+
846
+ // The control that actually stops credential stuffing: the counter follows the
847
+ // account, so spreading attempts across a proxy pool doesn't help.
848
+ func TestService_Login_LocksTheAccountAfterRepeatedFailures(t *testing.T) {
849
+ repo := &fakeRepo{
850
+ user: &domain.User{ID: uuid.New(), Email: "a@example.com"},
851
+ failures: map[string]int{},
852
+ }
853
+ svc := newTestService(repo, newFakeTokenStore())
854
+ in := LoginInput{Email: "a@example.com", Password: "wrong"}
855
+
856
+ // the free attempts answer "wrong password", not "locked"
857
+ for i := 0; i < loginFreeAttempts; i++ {
858
+ if _, err := svc.Login(context.Background(), in); err == nil {
859
+ t.Fatalf("attempt %d: expected a failure", i+1)
860
+ } else if code(err) != "AUTH_INVALID_CREDENTIALS" {
861
+ t.Fatalf("attempt %d: expected AUTH_INVALID_CREDENTIALS, got %s", i+1, code(err))
862
+ }
863
+ }
864
+
865
+ // the next one trips the lock...
866
+ if _, err := svc.Login(context.Background(), in); code(err) != "AUTH_INVALID_CREDENTIALS" {
867
+ t.Fatalf("the attempt that trips the lock still answers as a bad password, got %s", code(err))
868
+ }
869
+ // ...and everything after it is refused before any password is checked
870
+ if _, err := svc.Login(context.Background(), in); code(err) != "AUTH_TOO_MANY_ATTEMPTS" {
871
+ t.Fatalf("expected AUTH_TOO_MANY_ATTEMPTS once locked, got %s", code(err))
872
+ }
873
+ }
874
+
875
+ // A locked login must not lock the reset that fixes it — the person who forgot
876
+ // their password is exactly the person who trips the login counter.
877
+ func TestService_ForgotPassword_HasItsOwnCounter(t *testing.T) {
878
+ if throttleKey("login", "a@example.com") == throttleKey("pwreset", "a@example.com") {
879
+ t.Fatal("login and password-reset share a throttle key; a locked login would block recovery")
880
+ }
881
+ }
882
+
883
+ // code pulls the AppError code out, so a test asserting on behaviour doesn't
884
+ // have to care how the error is wrapped.
885
+ func code(err error) string {
886
+ var ruleErr *domain.RuleError
887
+ if errors.As(err, &ruleErr) {
888
+ return ruleErr.Code
889
+ }
890
+ return fmt.Sprintf("%v", err)
891
+ }