20 KiB
id, title, status, wave, depends_on, plan, spec
| id | title | status | wave | depends_on | plan | spec |
|---|---|---|---|---|---|---|
| 01-store-service-refresh | Store and service session-IP refresh | done | 1 | plan.md | ../../specs/auth/requirements/session-ip-refresh.md |
Task 01: Store and service session-IP refresh
Acceptance
Store.TouchSession(ctx, id, lastSeen, expires, ip string)updates the timestamps always and theipcolumn only whenipis non-empty (empty never clobbers a recorded value).Service.ResolveSessionToken(ctx, token, ip string)refreshes the stored session IP toipwhen the touch interval has elapsed,ipis non-empty, andipdiffers from the recorded value; same-IP, empty-IP, and within-interval requests never change the stored IP.- All existing call sites of both methods in the
authpackage compile with the new signatures, including the single production callerhttpmw.ResolveRequest(middleware.go:62, passing""— thec.ClientIP()wiring is Task 02), and the fullinternal/authtest suite passes. Without the middleware update,internal/httpmwfails to build and Task 01'sgo test ./internal/auth/...is unsatisfiable.
Files likely touched
apps/backend/internal/auth/store/store.go—TouchSessionsignature + conditionalipupdate.apps/backend/internal/auth/service_credentials.go—ResolveSessionTokengainsip; touch gate passes the refresh IP.apps/backend/internal/auth/store/store_test.go— newTestTouchSessionIPRefresh; existingTouchSessioncall gains"".apps/backend/internal/auth/service_test.go— newTestResolveSessionTokenRefreshesChangedIP; existingResolveSessionToken(ctx, token)calls gain"". No fixture change needed: the store is already reachable from package-auth tests asf.svc.store(same access asservice_sso_test.go'sf.svc.store.GetIdentityByProviderSubject).apps/backend/internal/auth/service_sso_test.go— existingResolveSessionToken(ctx, token)calls gain"".apps/backend/internal/auth/httpmw/middleware.go— mechanical signature update:svc.ResolveSessionToken(ctx, cookie)→svc.ResolveSessionToken(ctx, cookie, "")(keeps current behavior; thec.ClientIP()wiring is Task 02).
TDD sequence
- RED: extend the store test first —
TestTouchSessionIPRefreshwith anipargument and three transitions:1.1.1.1→ touch2.2.2.2updates the stored value;""preserves it; and a session created with an EMPTY stored IP → touch2.2.2.2BACKFILLS it (spec scenario 4). ROW MAPPING (explicit — the transitions span TWO targets): transitions 1-2 touch TARGET A (seeded IP1.1.1.1, seed timestamps relative to the first touch args); transition 3 touches TARGET B (seeded with EMPTY stored IP and its OWN seed timestamps — distinct from the touch args, using the same offsets as target A). Running all three touches against a single target would make the backfill assertion pass vacuously — the stored IP is already2.2.2.2when the backfill touch lands — silently dropping spec-scenario-4 coverage. Assert the decoy untouched after each of the THREE touches (both targets). Each transition MUST ALSO assert the timestamps moved:LastSeenAtandExpiresAtequal the touch arguments after the2.2.2.2,"", and backfill touches. Truncate the touch ARGS to the second and use DISTINCT values per column —lastSeen := now.Truncate(time.Second),expires := lastSeen.Add(time.Hour)(the existingstore_test.goconvention) — and assertgot.LastSeenAt.Equal(lastSeen)/got.ExpiresAt.Equal(expires). Identical args would let aTouchSessionwhose SET clause SWAPS the columns (SET expires_at = last_seen-arg, last_seen_at = expires-arg) write both columns to the same value and pass; distinct args make the swap fail deterministically. Advance the args per transition (lastSeen2 := lastSeen.Add(time.Second),expires2 := expires.Add(time.Second), and again for the backfill touch) so each transition'sEqual()pin is FRESH — reusing one pair across all three transitions makes the""transition's timestamp pin vacuous (timestamps already equal the asserted args after transition 1). NEVER==, which additionally compares the location POINTER and any monotonic reading and is fragile across parses.Equal()compares instants only: it absorbs location and monotonic differences, and truncating the args (not the stored values) keeps the comparison valid even where a driver truncates sub-second precision on write. (SQLite alone would often pass==— mattn's default format parses UTC andtime.Now().UTC()strips monotonic — do not let that tempt a "simplification".) Seed each TARGET and the DECOY with truncated, DISTINCT timestamps so the assertions cannot pass vacuously, all relative to the FIRST-transition arg pair — i.e.touchArgs := (lastSeen, expires)as defined above, the pair used by transition 1; the later transitions advance from it. TargetsLastSeenAt: touchArgs.lastSeen.Add(-time.Hour),ExpiresAt: touchArgs.expires.Add(24*time.Hour)— a target seeded with the same truncatednowas the touch arg would leave a dropped-last_seen_atcolumn equal to the asserted value; the decoy'sLastSeenAt/ExpiresAtoffset differently again (e.g.touchArgs.lastSeen.Add(-2*time.Hour)/touchArgs.expires.Add(48*time.Hour)) and itsIPset to a value distinct from the touch's non-empty ip arg (e.g.10.0.0.9), so a missing-WHEREupdate that writes identical timestamps to the decoy still trips the untouched assertions. MirrorsTestSessionLifecycle's seed-vs-touch distinctness (store_test.go:106-131; seed literal:111-115,CreateSessioninsert:116-118, touch:129). The "timestamps always update" half of the contract currently has ZERO coverage (TestSessionLifecycletouches and never asserts them), and aTouchSessionthat dropped the timestamp columns would pass an IP-only test while silently breaking sliding expiry. The fixture MUST ALSO insert an ADDITIONAL decoy session BEFORE any touch, and ALL THREE rows — target A, target B, and the decoy — MUST share ONEuser_id(e.g.u1; the decoy's token hash distinct). There are two targets and one decoy, so "same user_id as the target" is only satisfiable this way: if target B were seeded under a differentuser_id, transition 3's touch could never hit the decoy and aWHERE user_id = ?regression would go undetected on the backfill transition. Assert, after each touch of the target, that the decoy'sip,last_seen_at, andexpires_atare untouched — single-row fixtures cannot discriminate aTouchSessionthat dropsWHERE id = ?(updating everyauth_sessionsrow), and a decoy under a differentuser_idwould let aWHERE user_id = ?regression slip; no other planned test ASSERTS on a second same-user row after a touch (the service-test cases create multiple sessions on one fixture but assert only on the just-created row). Read back the target JUST touched plus the decoy after EACH transition, by their DISTINCT token hashes viaGetSessionByTokenHash— target A after transitions 1-2, target B after transition 3 (the fixture holds THREE rows: target A, target B, and the same-user_id decoy; "read both" is ambiguous and could leave one target's post-touch state unasserted) — never viaListSessionsByUserindexing: with a same-user_id decoy the list ordering is ambiguous (the decoy sits at index 1 only by virtue of its distinct seed, and the backfill session'slast_seen_atties the target's touch args) — consistent with the plan's "never by list index" rule. Build failure until the store changes. - Implement
TouchSession(single UPDATE withip = CASE WHEN ? = '' THEN ip ELSE ? END; positional arguments in orderlastSeen, expires, ip, ip, id— the duplicatedipbinds both the CASE test and the ELSE value); store tests GREEN. - RED: add the service test — build the fixture with
newServiceFixture(t, true)and create the aged sessions DIRECTLY, do NOT callsetupEnabled(it mints an extraDefaultUserIDsession that the TokenHash selection must then absorb — mirror task-02's no-setupAdmin recipe). For EACH case create a FRESH aged session (the store is reachable asf.svc.storefrom package auth — no fixture change; useGenerateSessionToken()— UNQUALIFIED:service_test.gois packageauthand imports noauthalias, so the qualified form is a compile error (undefined: auth); the qualifiedauth.form is only correct in task-02's package-httpmwtest — +f.svc.store.CreateSessionwithCreatedAt: now,IP: "1.1.1.1"— a NON-EMPTY stored IP for EVERY aged case (the different-IP, same-IP, and empty-request-IP bullets below; an empty seed would make the same-IP/empty-request-IP unchanged pins vacuous — only the BACKFILL case seeds an empty IP) —LastSeenAt: now.Add(-2*time.Hour)— at least 2 minutes in the past, because the strictnow.Sub(LastSeenAt) > sessionTouchIntervalgate needs a seed ≥60s old or the aged cases fail spuriously RED as a fixture defect —ExpiresAt: futurewith the SAMEfuture := now.Add(24*time.Hour)as the within-interval case — a narrow future expiry (e.g. 1m; a 1h expiry would only hazard a >1h stall) would let a >60s creation-to-resolve stall delete the session inResolveSessionToken(delete before touch,service_credentials.go:85-88) and fail RED for the wrong reason, the same hazard every other fixture in this task pins.UserIDmust beuserstore.DefaultUserID, the active row seeded byuserstore.Provide; a fabricated user ID failsactiveUserbefore the touch path runs, the same failure class as a past expiry. A fresh session per case is mandatory: the first resolve's touch bumpslast_seen_atto now, so reusing one aged session across cases would throttle the later resolves into vacuous passes without ever reachingTouchSession. Everysvc.ListSessionsassertion MUST select its row byTokenHash(or the session ID captured at creation), never by list index: the cases accumulate sessions underDefaultUserID, andListSessionsByUserorders bylast_seen_at DESCwith ties arbitrary — ordering is simply not relied on. In fact the within-interval row carries a FUTURE seededlast_seen_atand sorts ABOVE the touched rows (which carrynow), so a[0]-indexed different-IP assertion would pick the wrong row and fail spuriously. Cases: different IP →svc.ListSessionsshows the new IP; EMPTY stored IP + non-empty request IP → backfilled (spec scenario 4 — the discriminator for asess.IP != ""guard bug that would break backfill while passing every other case); same IP and empty request IP → the stored IP is unchanged (and, being aged sessions, theirlast_seen_atADVANCES — see the pin below;expires_atadvancement is pinned by the store-levelTestTouchSessionIPRefreshafter every touch, so the service pins can focus onlast_seen_at, the gate input); within-interval (session withIP: "1.1.1.1"— a NON-EMPTY stored IP matching the aged cases, so the "stored IP is unchanged" pin compares against a real recorded value; an empty stored IP would make the pin vacuous in the correct path — andLastSeenAt: seed, whereseed := now.Truncate(time.Second).Add(10*sessionTouchInterval)— packageauthCAN name the constant; truncate to the second for theEqual()assertion, per the store-test convention.ExpiresAt: futurewithfuture := now.Add(24*time.Hour): pinned here so the aged-extension below has a defined expiry; the value is never asserted, only future-ness, so widening is free and consistent with the store test's targetExpiresAtoffset (24h; the decoy offsets 48h). A narrow expiry (e.g. 1h) would let a >1h wall-clock stall between seed capture and the aged-extension's SECOND resolve delete the session (ResolveSessionTokendeletes expired sessions before touching) and fail RED for the wrong reason — the same failure class a past expiry already warns about. With 24h the binding constraint becomes the agedlast_seen_at(always open at D+2h > 60s). The strictnow.Sub(LastSeenAt) > sessionTouchIntervalgate keeps a seed 10 intervals in the future closed for ~11 minutes (10 intervals of future seed plus one interval of strict->threshold). Precisely, the gate opens when wall-clock elapsedDsatisfiesD > 660s − f, wheref ∈ [0,1s)is the fractional second the truncation dropped from the seed, i.e. between 659s and 660s. The stall window is bounded; the practical risk is negligible, the test runs in ms. — resolve with a DIFFERENT IP (2.2.2.2), assertingok == trueFIRST (the pins pass vacuously ifResolveSessionTokenreturns false for the future-seed session, a fixture defect or a buggy impl rejecting futurelast_seen_atrows) → the stored IP is unchanged (spec scenario 5's observable, exercised directly) ANDLastSeenAtSTILL equals the truncated seed (capture it before insert; assert viaEqual(); never a literal smaller seed, which would contradict the inserted value and go RED against a correct implementation). Read both pinned fields back viaGetSessionByTokenHashorsvc.ListSessions— either is touch-free and equivalent here; state the choice in the test so the mechanism is unambiguous. The different-IP resolve makes the always-touch variants self-revealing: a service that touches without the gate either writes2.2.2.2(the IP-unchanged assertion catches it) or bumpslast_seen_atfrom the future seed tonow(the pin catches it) — the last "no per-request writes" hole. Extend the same within-interval session to pin spec scenario 5's FULL transition (required, not optional — without it the scenario's second half is untested and the plan's scenario map overclaims): after the unchanged assertions, age the row viaf.svc.store.TouchSession(ctx, id, now.Add(-2*time.Hour), future, "")and resolve AGAIN with the same different IP → the stored IP is now updated ("updates on the next request after the interval"). The aged-session SAME-IP (or empty-IP) case MUST ALSO assert viasvc.ListSessionsthatlast_seen_atadvanced past the aged value AND is NOT afternow.Add(time.Minute), wherenowis RE-CAPTURED at assertion time — reusing the seed-capturenow(fromLastSeenAt: now.Add(-2*time.Hour)) would make a correct implementation fail spuriously on a >60s stall, the same time-sensitivity class the design hardens against elsewhere — AND thatExpiresAtis AFTER the seed's future expiry (e.g.got.ExpiresAt.After(seedExpires.Add(time.Hour))withseedExpires := futurecaptured at insert): the correct implementation writes resolve-now +SessionTTL()(now + 720hin the fixture), while a no-sliding-extension bug (passing the seed'sExpiresAtthrough unchanged) fails this. This is the SOLE service-level pin on expiry EXTENSION — task-02's read-back 401 only catches theexpires = nowvariant, and the store test pins fidelity to the passed arg, not the service's computation of it. The lower bound pins the "timestamps still touch" half of the service contract on the common production path (a buggy gate likeif ip != "" && ip != sess.IP { TouchSession(...) }stalls sliding expiry and fails the lower bound), while the upper bound pins the ARG ORDER of the service'sTouchSessioncall: a buggyTouchSession(ctx, id, now.Add(s.SessionTTL()), now, refreshIP)(args swapped) writeslast_seen_at = now + TTLand fails the upper bound, since a correct implementation writes the resolve-timenow. ExistingResolveSessionTokencalls need the new argument, so this is a build failure until the service changes. - Implement
ResolveSessionToken(ctx, token, ip)with the touch-gate refresh; update everyauthpackage call site plus the production callerhttpmw.ResolveRequest(passing""); service tests GREEN. - Run the full auth suite:
cd apps/backend && go test ./internal/auth/... -count=1.
Verification
cd apps/backend && go test ./internal/auth/store -run '^TestTouchSessionIPRefresh' -count=1 && go test ./internal/auth -run '^TestResolveSessionTokenRefreshesChangedIP' -count=1 && go test ./internal/auth/... -count=1
Dependencies
None.
Parallelism
sequential.
Inputs
- Spec What (refresh on touch when different; empty never overwrites;
login-time recording unchanged), Scenarios 1-6, Failure modes
(empty IP, failed touch write, bounded refresh). The "touch write fails
→ resolution still succeeds" failure mode is DELIBERATELY untested:
Service.store/Deps.Storeare the concrete*store.Storetype, so an error-injecting wrapper would require an interface change for test-only purposes. The contract holds by construction — the existing_ = s.store.TouchSession(...)best-effort call already ignores touch errors (unchanged behavior). Document, don't test. - Plan "Backend → Store / Service" sections for the exact signatures and SQL.
apps/backend/internal/auth/service_credentials.go:76-97(currentResolveSessionTokenand touch gate at:93-95),store.go:134-140(currentTouchSession),store_test.go(TestSessionLifecycle— touch call at:129),service_test.go(newServiceFixturebuildsauthStorebeforeNewService; the store is reachable asf.svc.store— no fixture struct change needed).
Docs to update
store.go:134—TouchSession's comment "updates activity timestamps (sliding expiry)" becomes inaccurate once the IP is conditionally refreshed; update to "updates activity timestamps (sliding expiry) and refreshes the stored client IP when a non-empty one is passed".service_credentials.go:75— extendResolveSessionToken's comment: the passediprefreshes the session IP on the touch path only when non-empty and differing from the recorded value.
Risks
- Gin's default trusted-proxies behavior is irrelevant here: the service
receives whatever IP the caller passes;
c.ClientIP()wiring is Task 02. - The within-interval case must use a fresh
LastSeenAtso the assertion is about the throttle, not about the IP comparison.
Output contract
Report the RED failure(s), GREEN result, exact files changed, the updated
call sites (enumerated: ResolveSessionToken — service_test.go:108,144,
152,184,189,192,220,264 (8, gain ""), service_sso_test.go:68,114 (2,
gain ""), httpmw/middleware.go:62 (1, mechanical "");
TouchSession — store_test.go:129 (1, gains "")), remaining risks, and
task/plan status sync. Record every exact
command and outcome in ## Results.
Results
RED (TDD step 1, store): build failure on the new signature —
vet: internal/auth/store/store_test.go:129:88: too many arguments in call to s.TouchSession.
GREEN (store): cd apps/backend && go test ./internal/auth/store -run '^TestTouchSessionIPRefresh' -count=1 → PASS.
RED (TDD step 3, service): build failure until ResolveSessionToken gains the
ip parameter (the new TestResolveSessionTokenRefreshesChangedIP calls the
3-arg form).
GREEN (service): cd apps/backend && go test ./internal/auth -run '^TestResolveSessionTokenRefreshesChangedIP' -count=1 -v → PASS, all 5 sub-cases
(different IP, backfill, same IP + touch pins, empty request IP + touch pins,
within-interval deferral + post-interval transition).
Full suite: cd apps/backend && go test ./internal/auth/... -count=1 → all
packages pass (auth, httpapi, httpmw, store; authn no tests).
Files changed: store/store.go (TouchSession signature + conditional-IP
UPDATE + comment), service_credentials.go (ResolveSessionToken signature,
touch-gate refresh, comment), store/store_test.go (new
TestTouchSessionIPRefresh, TestSessionLifecycle touch gains ""),
service_test.go (new TestResolveSessionTokenRefreshesChangedIP; 8
ResolveSessionToken(ctx, token) call sites gain ""), service_sso_test.go
(2 call sites gain ""), httpmw/middleware.go (1 call site, mechanical
"").
Remaining risks: none for this task; the c.ClientIP() wiring is Task 02.