test(08-06): add failing refresh-lifecycle RED anchor and store interface

TestPhase8RedLifecycleFramework drives exchange -> rotate -> replay against
the real (in-memory-backed) Server.Token and fails while rotateRefreshToken
is 08-04's invalid_grant placeholder (PHASE8_RED:lifecycle-framework,
verified fail-closed via scripts/check-phase8-red.sh). Extends the
RefreshTokenStore/AuthCodeStore interfaces with the store seams Task 2's
implementation needs (ByAPITokenIDForUpdate, MarkRotated,
DeleteExpiredCodes, DeleteExpiredRefreshTokens) and updates the framework's
in-memory test double to satisfy them.
This commit is contained in:
Jakub Zych
2026-09-23 21:26:05 +02:00
parent da01ea105c
commit b2c2cc0bb7
4 changed files with 545 additions and 18 deletions

View File

@@ -4,6 +4,7 @@ import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
@@ -22,6 +23,12 @@ type memoryBackend struct {
codes []*AuthCodeRecord
refresh []*RefreshTokenRecord
tokens []*IssuedToken
// revoked tracks IssuedToken.ID -> revoked, since IssuedToken itself
// carries no status field (it is the one-time mint result, not a
// queryable row). 08-06-PLAN.md's rotation/replay/revoke tests need to
// observe access-token revocation the same way real Postgres tests
// observe models.ApiToken.RevokedAt.
revoked map[uint]bool
nextID uint
}
@@ -164,6 +171,33 @@ func (t *memoryTx) ByTokenHashForUpdate(ctx context.Context, tokenHash string) (
return nil, nil
}
func (t *memoryTx) ByAPITokenIDForUpdate(ctx context.Context, apiTokenID uint) (*RefreshTokenRecord, error) {
for _, r := range t.b.refresh {
if r.APITokenID != nil && *r.APITokenID == apiTokenID {
cp := *r
return &cp, nil
}
}
return nil, nil
}
func (t *memoryTx) MarkRotated(ctx context.Context, id uint, successorID uint) error {
for _, r := range t.b.refresh {
if r.ID == id {
sid := successorID
r.RotatedToID = &sid
return nil
}
}
return nil
}
// RevokeLineage walks forward through RotatedToID starting at startID,
// stamping RevokedAt on every visited refresh row and marking each row's
// linked access token revoked too (08-06-PLAN.md: mirrors the GORM
// adapter's RevokeLineage so T-08-REFRESH-REPLAY's "kill the whole lineage"
// contract is provable against the in-memory backend, not just real
// Postgres).
func (t *memoryTx) RevokeLineage(ctx context.Context, startID uint) error {
now := time.Now()
id := startID
@@ -181,6 +215,12 @@ func (t *memoryTx) RevokeLineage(ctx context.Context, startID uint) error {
if found.RevokedAt == nil {
found.RevokedAt = &now
}
if found.APITokenID != nil {
if t.b.revoked == nil {
t.b.revoked = map[uint]bool{}
}
t.b.revoked[*found.APITokenID] = true
}
if found.RotatedToID == nil {
return nil
}
@@ -188,14 +228,46 @@ func (t *memoryTx) RevokeLineage(ctx context.Context, startID uint) error {
}
}
func (t *memoryTx) DeleteExpiredCodes(ctx context.Context, now time.Time) error {
kept := t.b.codes[:0:0]
for _, c := range t.b.codes {
if c.ExpiresAt.Before(now) {
continue
}
kept = append(kept, c)
}
t.b.codes = kept
return nil
}
func (t *memoryTx) DeleteExpiredRefreshTokens(ctx context.Context, now time.Time) error {
kept := t.b.refresh[:0:0]
for _, r := range t.b.refresh {
if r.ExpiresAt.Before(now) {
continue
}
kept = append(kept, r)
}
t.b.refresh = kept
return nil
}
// Mint's secret embeds the freshly-allocated id so two mints for the same
// client name (e.g. across a rotation) never collide on an identical
// "mem_<name>" string -- 08-06-PLAN.md's rotation tests distinguish the old
// and new access tokens by their returned secret.
func (t *memoryTx) Mint(ctx context.Context, userID uint, name string, scopes []string, expiresAt time.Time, collectionIDs []uint, clientID string) (IssuedToken, error) {
t.b.nextID++
tok := IssuedToken{ID: t.b.nextID, Secret: "mem_" + name}
tok := IssuedToken{ID: t.b.nextID, Secret: fmt.Sprintf("mem_%d_%s", t.b.nextID, name)}
t.b.tokens = append(t.b.tokens, &tok)
return tok, nil
}
func (t *memoryTx) Revoke(ctx context.Context, tokenID uint) error {
if t.b.revoked == nil {
t.b.revoked = map[uint]bool{}
}
t.b.revoked[tokenID] = true
return nil
}