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:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user