From 14fb4985aefca8fad36f58bfae3a3318052e40f6 Mon Sep 17 00:00:00 2001 From: gnacho Date: Fri, 23 Oct 2026 20:30:48 +0200 Subject: [PATCH] fix(go): port auth profile and password endpoints from app.ts PUT /api/auth/profile (partial profile update with zod-equivalent validation, display_name null coercion, users SSE broadcast) and PUT /api/auth/password (change with current-password check, other sessions invalidated) were never ported: they live in app.ts, which the routes-parity script did not cover. This broke language/profile changes in production (404). Both endpoints ported with round-trip tests, and the parity script now includes app.ts plus MethodFunc registrations, closing the gap (117 routes, full parity). --- scripts/verify-routes.mjs | 32 ++-- server-go/internal/api/server.go | 153 ++++++++++++++++++ .../internal/integration/integration_test.go | 77 +++++++++ 3 files changed, 252 insertions(+), 10 deletions(-) diff --git a/scripts/verify-routes.mjs b/scripts/verify-routes.mjs index 885dfdb..a224bb9 100644 --- a/scripts/verify-routes.mjs +++ b/scripts/verify-routes.mjs @@ -1,9 +1,13 @@ -// Verify the Go router registers the complete TypeScript route set (all -// route files; nothing deferred anymore). +// Verify the Go router registers the complete TypeScript route set. Sources: +// app.ts (auth endpoints + events + version + update) and every routes-*.ts. +// Go routes are registered with chi method helpers (r.Get("/api/...")) and +// with MethodFunc for the auth endpoints kept out of older comparisons. import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; -const root = '/home/nacho/temp/opencode-work/deltos-fase6'; +const root = fileURLToPath(new URL('..', import.meta.url)); const tsFiles = [ + `${root}/server/src/app.ts`, `${root}/server/src/routes-domain.ts`, `${root}/server/src/routes-ha.ts`, `${root}/server/src/routes-expenses.ts`, @@ -13,6 +17,7 @@ const tsFiles = [ `${root}/server/src/routes-push.ts`, ]; const goFiles = [ + `${root}/server-go/internal/api/server.go`, `${root}/server-go/internal/api/domain.go`, `${root}/server-go/internal/api/tasks.go`, `${root}/server-go/internal/api/ha.go`, @@ -21,17 +26,18 @@ const goFiles = [ `${root}/server-go/internal/api/decisions.go`, `${root}/server-go/internal/api/gamification.go`, `${root}/server-go/internal/api/push.go`, - `${root}/server-go/internal/api/server.go`, + `${root}/server-go/internal/api/update.go`, ]; const tsRe = /\.(get|post|put|delete|patch)\(\s*['"`](\/api\/[^'"`\s?]+)['"`]/g; const goRe = /\.(Get|Post|Put|Delete|Patch)\(\s*"(\/api\/[^"]+)"/g; +const goMethodFuncRe = /\.MethodFunc\(http\.Method([A-Za-z]+),\s*"(\/api\/[^"]+)"/g; -function collect(text, re) { +function collect(text, re, keyed) { const set = new Set(); let m; while ((m = re.exec(text)) !== null) { - set.add(m[1].toUpperCase() + ' ' + m[2]); + set.add((keyed ? keyed(m) : m[1].toUpperCase()) + ' ' + m[2]); } return set; } @@ -40,17 +46,23 @@ const tsSet = new Set(); for (const f of tsFiles) { for (const item of collect(readFileSync(f, 'utf8'), tsRe)) tsSet.add(item); } + const goSet = new Set(); for (const f of goFiles) { try { - for (const item of collect(readFileSync(f, 'utf8'), goRe)) goSet.add(item); + const t = readFileSync(f, 'utf8'); + for (const item of collect(t, goRe)) goSet.add(item); + for (const item of collect(t, goMethodFuncRe, (m) => m[1].toUpperCase())) goSet.add(item); } catch { // file not present yet: counts as missing routes } } -const missingInGo = [...tsSet].filter(r => !goSet.has(r)); -const unexpected = [...goSet].filter(r => !tsSet.has(r)); +// Go-side additions that the Node server does not have (documented). +const goOnly = new Set(['/api/auth/recover']); + +const missingInGo = [...tsSet].filter(r => !goSet.has(r) && !goOnly.has(r.split(' ')[1])); +const unexpected = [...goSet].filter(r => !tsSet.has(r) && !goOnly.has(r.split(' ')[1])); if (missingInGo.length) { console.error('FAIL: missing in Go:'); @@ -62,4 +74,4 @@ if (unexpected.length) { for (const r of unexpected.sort()) console.error(' ' + r); process.exit(1); } -console.log(`routes verification passed (${tsSet.size} routes, full parity)`); +console.log(`routes verification passed (${tsSet.size} routes + ${goOnly.size} documented addition, full parity)`); diff --git a/server-go/internal/api/server.go b/server-go/internal/api/server.go index 023af09..8db5ea0 100644 --- a/server-go/internal/api/server.go +++ b/server-go/internal/api/server.go @@ -12,6 +12,7 @@ import ( "net/http" "os" "path/filepath" + "regexp" "runtime" "strings" "time" @@ -171,6 +172,8 @@ func New(a *auth.Service, prod, demo *db.DB, cfg Config) *Server { ar.MethodFunc(http.MethodPost, "/api/auth/logout", s.handleLogout) ar.MethodFunc(http.MethodGet, "/api/auth/me", s.handleMe) ar.MethodFunc(http.MethodPost, "/api/auth/register", s.handleRegister) + ar.MethodFunc(http.MethodPut, "/api/auth/profile", s.handleProfile) + ar.MethodFunc(http.MethodPut, "/api/auth/password", s.handlePassword) ar.MethodFunc(http.MethodPost, "/api/auth/demo", s.handleDemoLogin) ar.MethodFunc(http.MethodPost, "/api/auth/recover", s.handleRecover) ar.MethodFunc(http.MethodGet, "/api/version", s.handleVersion) @@ -349,6 +352,156 @@ func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{"ok": true}) } +func (s *Server) handleProfile(w http.ResponseWriter, r *http.Request) { + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + validationFailed(w, []issue{{Path: []string{"body"}, Message: "invalid JSON"}}) + return + } + var issues []issue + updates := map[string]any{} + + if v, ok := body["display_name"]; ok { + if v == nil { + updates["display_name"] = nil + } else if s, isStr := v.(string); isStr { + trimmed := strings.TrimSpace(s) + if len(trimmed) > 50 { + issues = append(issues, issue{Path: []string{"display_name"}, Message: "max 50 characters"}) + } else if trimmed == "" { + updates["display_name"] = nil + } else { + updates["display_name"] = trimmed + } + } else { + issues = append(issues, issue{Path: []string{"display_name"}, Message: "string or null expected"}) + } + } + if v, ok := body["email"]; ok { + if v == nil { + updates["email"] = nil + } else if s, isStr := v.(string); isStr { + if len(s) > 120 { + issues = append(issues, issue{Path: []string{"email"}, Message: "max 120 characters"}) + } else if !emailRe.MatchString(s) { + issues = append(issues, issue{Path: []string{"email"}, Message: "invalid email"}) + } else { + updates["email"] = s + } + } else { + issues = append(issues, issue{Path: []string{"email"}, Message: "string or null expected"}) + } + } + if v, ok := body["phone"]; ok { + if v == nil { + updates["phone"] = nil + } else if s, isStr := v.(string); isStr { + if len(s) > 30 { + issues = append(issues, issue{Path: []string{"phone"}, Message: "max 30 characters"}) + } else { + updates["phone"] = s + } + } else { + issues = append(issues, issue{Path: []string{"phone"}, Message: "string or null expected"}) + } + } + if v, ok := body["language"]; ok { + s, isStr := v.(string) + if !isStr || (s != "auto" && s != "es" && s != "en") { + issues = append(issues, issue{Path: []string{"language"}, Message: "invalid language"}) + } else { + updates["language"] = s + } + } + if v, ok := body["color"]; ok { + s, isStr := v.(string) + if !isStr || len(s) < 2 || len(s) > 20 || !isLowerAlpha(s) { + issues = append(issues, issue{Path: []string{"color"}, Message: "invalid color"}) + } else { + updates["color"] = s + } + } + if v, ok := body["expenses_enabled"]; ok { + b, isBool := v.(bool) + if !isBool { + issues = append(issues, issue{Path: []string{"expenses_enabled"}, Message: "boolean expected"}) + } else { + updates["expenses_enabled"] = b + } + } + if v, ok := body["anti_slip_threshold"]; ok { + f, isNum := v.(float64) + if !isNum || f != float64(int64(f)) || f < 0 || f > 10 { + issues = append(issues, issue{Path: []string{"anti_slip_threshold"}, Message: "integer 0-10 expected"}) + } else { + updates["anti_slip_threshold"] = int64(f) + } + } + if len(issues) > 0 { + validationFailed(w, issues) + return + } + + user := auth.UserFrom(r) + updated, err := auth.UpdateUser(auth.DBFrom(r), user.ID, updates) + if err != nil { + errors.Write(w, errors.New(http.StatusInternalServerError, errors.InternalError)) + return + } + if updated == nil { + errors.Write(w, errors.New(http.StatusBadRequest, errors.BadRequest)) + return + } + s.Hub.Broadcast("users") + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "user": updated}) +} + +var emailRe = regexp.MustCompile(`^[^@\s]+@[^@\s]+\.[^@\s]+$`) + +type passwordRequest struct { + Current string `json:"current"` + Next string `json:"next"` +} + +func (s *Server) handlePassword(w http.ResponseWriter, r *http.Request) { + var body passwordRequest + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + validationFailed(w, []issue{{Path: []string{"body"}, Message: "invalid JSON"}}) + return + } + var issues []issue + if len(body.Current) < 1 { + issues = append(issues, issue{Path: []string{"current"}, Message: "indica la contraseƱa actual"}) + } + if len(body.Next) < 10 || len(body.Next) > 100 { + issues = append(issues, issue{Path: []string{"next"}, Message: "la nueva contraseƱa debe tener al menos 10 caracteres"}) + } + if len(issues) > 0 { + validationFailed(w, issues) + return + } + user := auth.UserFrom(r) + res, err := auth.ChangePassword(auth.DBFrom(r), user.ID, body.Current, body.Next) + if err != nil { + errors.Write(w, errors.New(http.StatusInternalServerError, errors.InternalError)) + return + } + switch res { + case "wrong-current": + errors.Write(w, errors.New(http.StatusBadRequest, errors.AuthWrongPassword)) + return + case "ok": + // Invalidate every other session of the user; the current one survives. + if err := auth.DestroyOtherSessions(auth.DBFrom(r), user.ID, auth.SessionIDFrom(r)); err != nil { + errors.Write(w, errors.New(http.StatusInternalServerError, errors.InternalError)) + return + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) + default: + errors.Write(w, errors.New(http.StatusInternalServerError, errors.InternalError)) + } +} + func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) { csrfToken := any(nil) if res := s.Auth.ResolveSession(r); res != nil { diff --git a/server-go/internal/integration/integration_test.go b/server-go/internal/integration/integration_test.go index 0813ace..4fcabd0 100644 --- a/server-go/internal/integration/integration_test.go +++ b/server-go/internal/integration/integration_test.go @@ -329,6 +329,83 @@ func TestMethodNotAllowedMatchesHono(t *testing.T) { } } +func TestProfileAndPasswordRoundTrip(t *testing.T) { + ts, _, _, _ := boot(t) + client := &http.Client{Timeout: 10 * time.Second} + + // login + res, body := postJSON(t, ts.URL+"/api/auth/login", + map[string]string{"username": adminUser, "password": adminPassword}, nil) + if res.StatusCode != 200 { + t.Fatalf("login must 200, got %d", res.StatusCode) + } + csrf, _ := body["csrfToken"].(string) + cookies := res.Cookies() + + put := func(path string, payload any, sendCSRF bool) (int, map[string]any) { + b, _ := json.Marshal(payload) + req, _ := http.NewRequest(http.MethodPut, ts.URL+path, bytes.NewReader(b)) + req.Header.Set("Content-Type", "application/json") + for _, c := range cookies { + req.AddCookie(c) + } + if sendCSRF { + req.Header.Set("X-Csrf-Token", csrf) + } + res, err := client.Do(req) + if err != nil { + t.Fatal(err) + } + var out map[string]any + _ = json.NewDecoder(res.Body).Decode(&out) + res.Body.Close() + return res.StatusCode, out + } + + // profile: language change (the reported bug) + code, out := put("/api/auth/profile", map[string]any{"language": "en"}, true) + if code != 200 { + t.Fatalf("profile language change must 200, got %d: %v", code, out) + } + u, _ := out["user"].(map[string]any) + if u == nil || u["language"] != "en" || out["ok"] != true { + t.Fatalf("profile must return ok + updated user: %v", out) + } + + // profile: invalid language rejected with 422 + issues + code, _ = put("/api/auth/profile", map[string]any{"language": "fr"}, true) + if code != 422 { + t.Fatalf("invalid language must 422, got %d", code) + } + + // profile: empty display_name becomes null + code, out = put("/api/auth/profile", map[string]any{"display_name": ""}, true) + if code != 200 { + t.Fatalf("empty display_name must 200, got %d", code) + } + u, _ = out["user"].(map[string]any) + if _, present := u["display_name"]; !present { + t.Fatalf("display_name key must be present (null)") + } + + // password: wrong current -> 400 AUTH_WRONG_CURRENT_PASSWORD + code, _ = put("/api/auth/password", map[string]string{"current": "nope", "next": "newpassword-10"}, true) + if code != 400 { + t.Fatalf("wrong current must 400, got %d", code) + } + + // password: correct current -> ok, then re-login with the new one + code, _ = put("/api/auth/password", map[string]string{"current": adminPassword, "next": "newpassword-10"}, true) + if code != 200 { + t.Fatalf("password change must 200, got %d", code) + } + res, _ = postJSON(t, ts.URL+"/api/auth/login", + map[string]string{"username": adminUser, "password": "newpassword-10"}, nil) + if res.StatusCode != 200 { + t.Fatalf("login with new password must 200, got %d", res.StatusCode) + } +} + func TestLoginLockoutEndToEnd(t *testing.T) { ts, _, _, _ := boot(t) for i := 0; i < 5; i++ {