fix(api): 收到 API 时钟的 repo 写操作一律用它打时间戳,不再和 PG now() 混用

This commit is contained in:
Lemon-miaow committed 2026-09-27 14:15:03 +08:00
1 parent e491e063cf
commit e9e9a7a622
9 files changed
+211 -38

No files matched your search

+164
View File
@@ -0,0 +1,164 @@
//go:build pgint
package pgint
import (
"context"
"errors"
"testing"
"time"
"felis.lolicon.best/internal/api"
)
// A method handed the API clock writes only that clock (PGRepo's doc). Each case
// runs the API clock an hour behind the database's, so a time taken from now()
// instead lands an hour away from the one the method was given.
func skewedClock() time.Time { return mustNow().Add(-time.Hour).Truncate(time.Microsecond) }
func stampAt(t *testing.T, q string, args ...any) time.Time {
t.Helper()
var at time.Time
if err := db.QueryRow(q, args...).Scan(&at); err != nil {
t.Fatalf("%s: %v", q, err)
}
return at
}
func wantStamp(t *testing.T, what string, got, want time.Time) {
t.Helper()
if !sameMicro(got, want) {
t.Errorf("%s = %v, want the API clock's %v", what, got, want)
}
}
// A session is created and last seen at its sign-in on the API clock, which is
// also the clock the staff idle cutoff reads.
func TestSessionTimesComeFromTheSignIn(t *testing.T) {
ctx := context.Background()
now := skewedClock()
create := func(userID, tag string, signedIn time.Time) string {
t.Helper()
hash := tag + "-" + suffix(t)
if err := repo.CreateSession(ctx, api.NewSession{
TokenHash: hash, UserID: userID, CreatedAt: signedIn, ExpiresAt: now.Add(time.Hour),
}); err != nil {
t.Fatalf("CreateSession(%s): %v", tag, err)
}
return hash
}
admin := newUser(t, "admin", "clk-admin")
stale := create(admin.ID, "clk-stale", now.Add(-31*time.Minute))
fresh := create(admin.ID, "clk-fresh", now.Add(-29*time.Minute))
if _, err := repo.SessionUser(ctx, stale, now); !errors.Is(err, api.ErrNotFound) {
t.Fatalf("staff session signed in 31m ago = %v, want ErrNotFound (idled out)", err)
}
if _, err := repo.SessionUser(ctx, fresh, now); err != nil {
t.Fatalf("staff session signed in 29m ago: %v", err)
}
player := newUser(t, "user", "clk-player")
signedIn := now.Add(-2 * time.Hour)
hash := create(player.ID, "clk-player", signedIn)
ss, err := repo.ListUserSessions(ctx, player.ID, now)
if err != nil || len(ss) != 1 || ss[0].TokenHash != hash {
t.Fatalf("ListUserSessions = %+v, %v; want only %s", ss, err, hash)
}
wantStamp(t, "created_at", ss[0].CreatedAt, signedIn)
wantStamp(t, "last_seen_at", ss[0].LastSeenAt, signedIn)
}
// Every door that writes a link stamps it, and whatever else its transaction
// creates, at the time it was given.
func TestLinkWritesStampTheAPIClock(t *testing.T) {
ctx := context.Background()
now := skewedClock()
code := func(mc string) string {
t.Helper()
c := "clk-" + suffix(t)
if err := repo.CreateLinkCode(ctx, c, mc, "mojang", now.Add(10*time.Minute)); err != nil {
t.Fatalf("CreateLinkCode: %v", err)
}
return c
}
linkedAt := func(mc string) time.Time {
t.Helper()
return stampAt(t, `SELECT verified_at FROM account_links WHERE mc_uuid = $1`, mc)
}
createdAt := func(userID string) time.Time {
t.Helper()
return stampAt(t, `SELECT created_at FROM users WHERE id = $1`, userID)
}
t.Run("verify", func(t *testing.T) {
u := newUser(t, "user", "clk-verify")
mc := testUUID(t)
if _, _, err := repo.VerifyLinkCode(ctx, u.ID, code(mc), now); err != nil {
t.Fatalf("VerifyLinkCode: %v", err)
}
wantStamp(t, "verified_at", linkedAt(mc), now)
})
t.Run("takeover of a retired link", func(t *testing.T) {
retired := newUser(t, "user", "clk-retired")
mc := testUUID(t)
if err := repo.LinkAccount(ctx, retired.ID, mc, "mojang"); err != nil {
t.Fatalf("LinkAccount: %v", err)
}
mustExec(t, `UPDATE users SET disabled = true, deleted_at = now() WHERE id = $1`, retired.ID)
taker := newUser(t, "user", "clk-taker")
if _, _, err := repo.VerifyLinkCode(ctx, taker.ID, code(mc), now); err != nil {
t.Fatalf("takeover VerifyLinkCode: %v", err)
}
wantStamp(t, "verified_at", linkedAt(mc), now)
})
t.Run("player bind", func(t *testing.T) {
mc := testUUID(t)
id := "usr-clk-player-" + suffix(t)
if got, _, _, err := repo.RedeemPlayerBindCode(ctx, id, code(mc), now); err != nil || got != id {
t.Fatalf("RedeemPlayerBindCode = %q, %v; want %s", got, err, id)
}
wantStamp(t, "user created_at", createdAt(id), now)
wantStamp(t, "verified_at", linkedAt(mc), now)
})
t.Run("owner setup", func(t *testing.T) {
mc := testUUID(t)
id := "usr-clk-owner-" + suffix(t)
tok := "tok-clk-" + suffix(t)
if got, _, _, err := repo.CompleteOwnerSetup(ctx, id, code(mc), now, tok, now.Add(time.Hour)); err != nil || got != id {
t.Fatalf("CompleteOwnerSetup = %q, %v; want %s", got, err, id)
}
wantStamp(t, "user created_at", createdAt(id), now)
wantStamp(t, "verified_at", linkedAt(mc), now)
wantStamp(t, "setup token created_at",
stampAt(t, `SELECT created_at FROM setup_tokens WHERE token_hash = $1`, tok), now)
wantStamp(t, "local auth updated_at",
stampAt(t, `SELECT updated_at FROM platform_settings WHERE key = $1`, api.LocalAuthEnabledKey), now)
})
}
// The redeem moves the servers, retires the source and ends its sessions at the
// instant it records the migration redeemed.
func TestMigrationRedeemStampsTheAPIClock(t *testing.T) {
ctx := context.Background()
now := skewedClock()
src, dst := newUser(t, "user", "clk-src"), newUser(t, "user", "clk-dst")
server := "clk-srv-" + suffix(t)
seedOwnedServer(t, server, src.ID, false)
sess := newSession(t, src.ID, "clk-src", now.Add(time.Hour))
hash := "h-clk-" + suffix(t)
startToCode(t, src.ID, dst.ID, hash, now, now.Add(10*time.Minute))
if _, moved, err := repo.RedeemMigration(ctx, dst.ID, hash, now); err != nil || len(moved) != 1 {
t.Fatalf("RedeemMigration = %v, %v; want [%s]", moved, err, server)
}
wantStamp(t, "redeemed_at",
stampAt(t, `SELECT redeemed_at FROM account_migrations WHERE source_user_id = $1 AND state = 'redeemed'`, src.ID), now)
wantStamp(t, "server claimed_at", stampAt(t, `SELECT claimed_at FROM servers WHERE name = $1`, server), now)
wantStamp(t, "source deleted_at", stampAt(t, `SELECT deleted_at FROM users WHERE id = $1`, src.ID), now)
wantStamp(t, "source session revoked_at", stampAt(t, `SELECT revoked_at FROM sessions WHERE token_hash = $1`, sess), now)
}