From 9d2504c42d2d99ad8d57f04fabef6bed8dde86c4 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Fri, 8 May 2026 11:58:47 +0200 Subject: [PATCH] backend: embed tzdata so time.LoadLocation works in distroless/alpine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `time.LoadLocation` is called from backend/internal/server/handlers_public_auth.go:108 (confirm-email-code) and backend/internal/user/account.go:218 (user.settings.update). Both runtime images shipped today have no tzdata — production backend/Dockerfile uses gcr.io/distroless/static-debian12:nonroot, and local-dev tools/local-dev/backend.Dockerfile uses alpine:3.20 without the optional tzdata apk — so the container-side binary resolves only the no-data fallback (UTC and fixed offsets) and rejects every real IANA zone with HTTP 400 `invalid_request: time_zone must be a valid IANA zone`. Adding `import _ "time/tzdata"` to backend's main is the idiomatic Go fix: the binary embeds the IANA database, time.LoadLocation works on every base image, no Dockerfile changes needed. Cost is ~800 KB of binary growth — invisible next to the existing /usr/local/bin/backend size and well below any container layer threshold. The OpenAPI spec already documents the field as "IANA time-zone identifier" (gateway/openapi.yaml:205, backend/openapi.yaml:2334) and the UI sends Intl.DateTimeFormat().resolvedOptions().timeZone, so neither the contract nor the client needs a change. Why this slipped through: backend unit tests run as a host Go test process (developer's tzdata covers them), Playwright tests mock the gateway (backend never reached), and the integration suite — the only layer that exercises the real backend container — uses RegisterSession which hardcoded `time_zone="UTC"`. Switching that default to "Europe/Berlin" makes every integration scenario that enrols a pilot exercise the tzdata path, so the next regression surfaces in the integration run instead of escaping into manual smoke. (The integration suite is not in the per-PR workflow yet; that gap is tracked separately.) Verified end-to-end against `tools/local-dev`: - Europe/Amsterdam, Asia/Tokyo, America/Los_Angeles → 200 + device_session_id (was 400 before this patch). - Mars/Olympus still → 400 (validation behaviour unchanged). Host tests: backend/internal/{auth,user,config} green. UI: pnpm test 14/14, CI=1 pnpm exec playwright test 44/44. Co-Authored-By: Claude Opus 4.7 --- backend/cmd/backend/main.go | 7 +++++++ integration/testenv/session.go | 7 ++++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/backend/cmd/backend/main.go b/backend/cmd/backend/main.go index b558c4c..0efd1d4 100644 --- a/backend/cmd/backend/main.go +++ b/backend/cmd/backend/main.go @@ -13,6 +13,13 @@ import ( "os/signal" "syscall" + // time/tzdata embeds the IANA timezone database so time.LoadLocation + // works in container images without /usr/share/zoneinfo (distroless + // static, alpine without the tzdata apk). The auth and user-settings + // flows validate the caller's `time_zone` via time.LoadLocation; + // without this import only "UTC" and fixed offsets would resolve. + _ "time/tzdata" + "galaxy/backend/internal/admin" "galaxy/backend/internal/app" "galaxy/backend/internal/auth" diff --git a/integration/testenv/session.go b/integration/testenv/session.go index 95054d0..ae20aae 100644 --- a/integration/testenv/session.go +++ b/integration/testenv/session.go @@ -63,7 +63,12 @@ func RegisterSession(t *testing.T, plat *Platform, email string) *Session { } code := m[1] - confirm, _, err := public.ConfirmEmailCode(ctx, send.ChallengeID, code, EncodePublicKey(pub), "UTC") + // Pass a non-UTC IANA zone so every integration scenario that + // enrols a pilot exercises the time.LoadLocation path. UTC works + // even when the backend image lacks tzdata (Go's no-data fallback + // covers it), so a regression that drops the embedded tzdata + // import would otherwise slip past unnoticed. + confirm, _, err := public.ConfirmEmailCode(ctx, send.ChallengeID, code, EncodePublicKey(pub), "Europe/Berlin") if err != nil { t.Fatalf("confirm-email-code: %v", err) }