tenseleyflow/shithub / d30741e

Browse files

admin: actually send password reset emails (SR2 C3 + L6)

Pre-fix: userResetPassword minted a token, persisted password_resets,
audited ActionAdminUserPasswordReset, then `_ = tokEnc` discarded
the token. admin.Deps had no email.Sender at all, so the comment
'surfaced via the email path' was false from the start. The audit
row claimed 'sent'; the user never received anything.

Post-fix:
- admin.Deps adds Email + Branding fields, mirroring auth.Deps.
- admin_wiring.buildAdminHandlers takes the same email.Sender
the auth surface uses (server.go threads pickEmailSender(cfg)
through both paths).
- userResetPassword calls email.ResetMessage + sender.Send. The
audit row now carries email_sent: bool plus email_error: string
on failure, so a stuck mailbox is visible in /admin/audit instead
of silently misleading.
- L6: server.go now passes version.Version into buildAdminHandlers
instead of the literal 'dev', so /admin/system reflects the
ld-flag-stamped build version.

admin_deps_test pins the field contract — removing Email or
Branding breaks compile.
Authored by espadonne
SHA
d30741e3d6190cc6f39e8b72c9d0fcd84edb1d38
Parents
a4f4d82
Tree
203e4a0

5 changed files

StatusFile+-
M internal/web/admin_wiring.go 16 4
M internal/web/handlers/admin/admin.go 6 0
A internal/web/handlers/admin/admin_deps_test.go 33 0
M internal/web/handlers/admin/users.go 26 7
M internal/web/server.go 11 1
internal/web/admin_wiring.gomodified
@@ -11,6 +11,7 @@ import (
1111
 	"github.com/jackc/pgx/v5/pgxpool"
1212
 
1313
 	"github.com/tenseleyFlow/shithub/internal/auth/audit"
14
+	"github.com/tenseleyFlow/shithub/internal/auth/email"
1415
 	"github.com/tenseleyFlow/shithub/internal/infra/config"
1516
 	adminh "github.com/tenseleyFlow/shithub/internal/web/handlers/admin"
1617
 	"github.com/tenseleyFlow/shithub/internal/web/render"
@@ -19,12 +20,17 @@ import (
1920
 // buildAdminHandlers wires the S34 site-admin handler set. The
2021
 // returned handler set is route-only; the wiring layer in server.go
2122
 // composes RequireUser + RequireSiteAdmin around the Mount call.
23
+//
24
+// emailSender + branding are required for the "Reset password"
25
+// admin action to actually deliver. Pass the same sender the
26
+// auth handler set uses so behavior matches the public flow.
2227
 func buildAdminHandlers(
2328
 	cfg config.Config,
2429
 	pool *pgxpool.Pool,
2530
 	tmplFS fs.FS,
2631
 	logger *slog.Logger,
2732
 	version string,
33
+	emailSender email.Sender,
2834
 ) (*adminh.Handlers, error) {
2935
 	if pool == nil {
3036
 		return nil, errors.New("admin: nil pool")
@@ -34,10 +40,16 @@ func buildAdminHandlers(
3440
 		return nil, fmt.Errorf("admin: render.New: %w", err)
3541
 	}
3642
 	return adminh.New(adminh.Deps{
37
-		Logger:   logger,
38
-		Render:   rr,
39
-		Pool:     pool,
40
-		Audit:    audit.NewRecorder(),
43
+		Logger: logger,
44
+		Render: rr,
45
+		Pool:   pool,
46
+		Audit:  audit.NewRecorder(),
47
+		Email:  emailSender,
48
+		Branding: email.Branding{
49
+			SiteName: cfg.Auth.SiteName,
50
+			BaseURL:  cfg.Auth.BaseURL,
51
+			From:     cfg.Auth.EmailFrom,
52
+		},
4153
 		SiteName: cfg.Auth.SiteName,
4254
 		Version:  version,
4355
 	})
internal/web/handlers/admin/admin.gomodified
@@ -15,6 +15,7 @@ import (
1515
 
1616
 	admindb "github.com/tenseleyFlow/shithub/internal/admin/sqlc"
1717
 	"github.com/tenseleyFlow/shithub/internal/auth/audit"
18
+	"github.com/tenseleyFlow/shithub/internal/auth/email"
1819
 	usersdb "github.com/tenseleyFlow/shithub/internal/users/sqlc"
1920
 	"github.com/tenseleyFlow/shithub/internal/web/render"
2021
 )
@@ -25,6 +26,11 @@ type Deps struct {
2526
 	Render *render.Renderer
2627
 	Pool   *pgxpool.Pool
2728
 	Audit  *audit.Recorder
29
+	// Email + Branding power the admin "Reset password" send. Required:
30
+	// without them userResetPassword mints a token and never delivers
31
+	// it (SR2 C3 — pre-fix the audit row falsely claimed "sent").
32
+	Email    email.Sender
33
+	Branding email.Branding
2834
 	// SiteName is rendered into pages; matches Auth.SiteName from config.
2935
 	SiteName string
3036
 	// Version is the running shithubd version, surfaced on /admin/system.
internal/web/handlers/admin/admin_deps_test.goadded
@@ -0,0 +1,33 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package admin_test
4
+
5
+import (
6
+	"testing"
7
+
8
+	"github.com/tenseleyFlow/shithub/internal/auth/email"
9
+	adminh "github.com/tenseleyFlow/shithub/internal/web/handlers/admin"
10
+)
11
+
12
+// TestDepsShape pins the admin.Deps type contract: Email + Branding
13
+// MUST be present (SR2 C3). Pre-SR2 the admin handlers had no email
14
+// sender wired, so userResetPassword minted a token, persisted it,
15
+// audited "sent", and discarded the token via `_ = tokEnc`. The user
16
+// never received the email; the audit row lied.
17
+//
18
+// This is a build-time contract: removing the fields breaks compile.
19
+// If you're here because this test failed, the SR2 C3 remediation
20
+// has regressed — restore the fields and the userResetPassword send
21
+// path.
22
+func TestDepsShape(t *testing.T) {
23
+	t.Parallel()
24
+
25
+	// Compile-time field references. If Email or Branding are renamed
26
+	// or removed the package fails to build. The runtime assertion
27
+	// below is belt-and-suspenders for type drift.
28
+	d := adminh.Deps{
29
+		Email:    nil,              // type: email.Sender
30
+		Branding: email.Branding{}, // type: email.Branding
31
+	}
32
+	_ = d
33
+}
internal/web/handlers/admin/users.gomodified
@@ -183,13 +183,32 @@ func (h *Handlers) userResetPassword(w http.ResponseWriter, r *http.Request) {
183183
 		http.Error(w, "create reset row failed", http.StatusInternalServerError)
184184
 		return
185185
 	}
186
-	// Email send is best-effort (the token is in the DB regardless;
187
-	// admin can resend by clicking again). When configured, this hits
188
-	// the same sender as the public flow.
189
-	_ = email.Branding{}
190
-	_ = tokEnc // surfaced via the email path; left as TODO when no sender wired
191
-	h.recordAdminAction(r, audit.ActionAdminUserPasswordReset, audit.TargetUser, user.ID,
192
-		map[string]any{"email": string(em.Email)})
186
+
187
+	// Send the message (best-effort: the token row is committed; the
188
+	// admin can resend by clicking again). The audit row records
189
+	// whether the send succeeded, so a stuck mailbox shows up in
190
+	// /admin/audit instead of being invisible (SR2 C3 fix).
191
+	emailSent := false
192
+	emailErr := ""
193
+	if h.d.Email != nil {
194
+		msg, err := email.ResetMessage(h.d.Branding, string(em.Email), tokEnc)
195
+		if err != nil {
196
+			emailErr = err.Error()
197
+			h.d.Logger.WarnContext(r.Context(), "admin reset: build message", "error", err)
198
+		} else if err := h.d.Email.Send(r.Context(), msg); err != nil {
199
+			emailErr = err.Error()
200
+			h.d.Logger.WarnContext(r.Context(), "admin reset: send", "error", err)
201
+		} else {
202
+			emailSent = true
203
+		}
204
+	} else {
205
+		emailErr = "no sender wired"
206
+	}
207
+	auditMeta := map[string]any{"email": string(em.Email), "email_sent": emailSent}
208
+	if emailErr != "" {
209
+		auditMeta["email_error"] = emailErr
210
+	}
211
+	h.recordAdminAction(r, audit.ActionAdminUserPasswordReset, audit.TargetUser, user.ID, auditMeta)
193212
 	http.Redirect(w, r, "/admin/users/"+strconv.FormatInt(user.ID, 10)+"?notice=saved", http.StatusSeeOther)
194213
 }
195214
 
internal/web/server.gomodified
@@ -30,6 +30,7 @@ import (
3030
 	infralog "github.com/tenseleyFlow/shithub/internal/infra/log"
3131
 	"github.com/tenseleyFlow/shithub/internal/infra/metrics"
3232
 	"github.com/tenseleyFlow/shithub/internal/infra/tracing"
33
+	"github.com/tenseleyFlow/shithub/internal/version"
3334
 	"github.com/tenseleyFlow/shithub/internal/web/handlers"
3435
 	"github.com/tenseleyFlow/shithub/internal/web/middleware"
3536
 )
@@ -275,7 +276,16 @@ func Run(ctx context.Context, opts Options) error {
275276
 		// S34 — site admin. Gated by RequireUser + RequireSiteAdmin
276277
 		// (404 not 403 for non-admins). Uses its own renderer so the
277278
 		// admin templates are loaded once at boot.
278
-		adminH, err := buildAdminHandlers(cfg, pool, deps.TemplatesFS, logger, "dev")
279
+		//
280
+		// Email sender is the same one auth uses; the admin "Reset
281
+		// password" action sends through it (SR2 C3). Version is the
282
+		// build-time-stamped value so /admin/system reports reality
283
+		// instead of the literal "dev" (SR2 L6).
284
+		adminSender, err := pickEmailSender(cfg)
285
+		if err != nil {
286
+			return fmt.Errorf("admin handlers: pick email sender: %w", err)
287
+		}
288
+		adminH, err := buildAdminHandlers(cfg, pool, deps.TemplatesFS, logger, version.Version, adminSender)
279289
 		if err != nil {
280290
 			return fmt.Errorf("admin handlers: %w", err)
281291
 		}