Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 22 additions & 10 deletions scripts/verify-routes.mjs
Original file line number Diff line number Diff line change
@@ -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`,
Expand All @@ -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`,
Expand All @@ -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;
}
Expand All @@ -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:');
Expand All @@ -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)`);
153 changes: 153 additions & 0 deletions server-go/internal/api/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"net/http"
"os"
"path/filepath"
"regexp"
"runtime"
"strings"
"time"
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 {
Expand Down
77 changes: 77 additions & 0 deletions server-go/internal/integration/integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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++ {
Expand Down
Loading