backend: embed tzdata so time.LoadLocation works in distroless/alpine

`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 <noreply@anthropic.com>
This commit is contained in:
Ilia Denisov
2026-05-08 11:58:47 +02:00
parent 6f6a854337
commit 9d2504c42d
2 changed files with 13 additions and 1 deletions
+7
View File
@@ -13,6 +13,13 @@ import (
"os/signal" "os/signal"
"syscall" "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/admin"
"galaxy/backend/internal/app" "galaxy/backend/internal/app"
"galaxy/backend/internal/auth" "galaxy/backend/internal/auth"
+6 -1
View File
@@ -63,7 +63,12 @@ func RegisterSession(t *testing.T, plat *Platform, email string) *Session {
} }
code := m[1] 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 { if err != nil {
t.Fatalf("confirm-email-code: %v", err) t.Fatalf("confirm-email-code: %v", err)
} }