Merge remote-tracking branch 'upstream/main' into dev
This commit is contained in:
commit
6b29556ef8
836 changed files with 1598388 additions and 64684 deletions
|
|
@ -166,57 +166,66 @@ func scanAdminCommand(row pgx.Row) (domain.AdminCommand, error) {
|
|||
return cmd, nil
|
||||
}
|
||||
|
||||
func (s *AdminStore) GetSendRestriction(ctx context.Context, userID int64) (domain.AccountSendRestriction, bool, error) {
|
||||
func (s *AdminStore) GetAccountFreeze(ctx context.Context, userID int64) (domain.AccountFreeze, bool, error) {
|
||||
row := s.db.QueryRow(ctx, `
|
||||
SELECT user_id, frozen, reason, actor, command_id, updated_at
|
||||
FROM account_send_restrictions
|
||||
SELECT user_id, frozen, frozen_since, frozen_until, appeal_url, reason, actor, command_id, updated_at
|
||||
FROM account_restrictions
|
||||
WHERE user_id = $1`, userID)
|
||||
r, err := scanSendRestriction(row)
|
||||
r, err := scanAccountFreeze(row)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.AccountSendRestriction{}, false, nil
|
||||
return domain.AccountFreeze{}, false, nil
|
||||
}
|
||||
return domain.AccountSendRestriction{}, false, fmt.Errorf("get send restriction: %w", err)
|
||||
return domain.AccountFreeze{}, false, fmt.Errorf("get account freeze: %w", err)
|
||||
}
|
||||
return r, true, nil
|
||||
}
|
||||
|
||||
func (s *AdminStore) SetSendRestriction(ctx context.Context, restriction domain.AccountSendRestriction) (domain.AccountSendRestriction, error) {
|
||||
func (s *AdminStore) SetAccountFreeze(ctx context.Context, freeze domain.AccountFreeze) (domain.AccountFreeze, error) {
|
||||
var since, until any
|
||||
if freeze.Frozen {
|
||||
since = freeze.Since
|
||||
until = freeze.Until
|
||||
}
|
||||
row := s.db.QueryRow(ctx, `
|
||||
INSERT INTO account_send_restrictions (user_id, frozen, reason, actor, command_id, updated_at)
|
||||
VALUES ($1,$2,$3,$4,$5,now())
|
||||
INSERT INTO account_restrictions (
|
||||
user_id, frozen, frozen_since, frozen_until, appeal_url, reason, actor, command_id, updated_at
|
||||
)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,now())
|
||||
ON CONFLICT (user_id) DO UPDATE SET
|
||||
frozen = EXCLUDED.frozen,
|
||||
frozen_since = EXCLUDED.frozen_since,
|
||||
frozen_until = EXCLUDED.frozen_until,
|
||||
appeal_url = EXCLUDED.appeal_url,
|
||||
reason = EXCLUDED.reason,
|
||||
actor = EXCLUDED.actor,
|
||||
command_id = EXCLUDED.command_id,
|
||||
updated_at = now()
|
||||
RETURNING user_id, frozen, reason, actor, command_id, updated_at`,
|
||||
restriction.UserID, restriction.Frozen, restriction.Reason, restriction.Actor, restriction.CommandID,
|
||||
RETURNING user_id, frozen, frozen_since, frozen_until, appeal_url, reason, actor, command_id, updated_at`,
|
||||
freeze.UserID, freeze.Frozen, since, until, freeze.AppealURL, freeze.Reason, freeze.Actor, freeze.CommandID,
|
||||
)
|
||||
out, err := scanSendRestriction(row)
|
||||
out, err := scanAccountFreeze(row)
|
||||
if err != nil {
|
||||
return domain.AccountSendRestriction{}, fmt.Errorf("set send restriction: %w", err)
|
||||
return domain.AccountFreeze{}, fmt.Errorf("set account freeze: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *AdminStore) IsSendFrozen(ctx context.Context, userID int64) (bool, error) {
|
||||
var frozen bool
|
||||
if err := s.db.QueryRow(ctx, `SELECT frozen FROM account_send_restrictions WHERE user_id = $1`, userID).Scan(&frozen); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return false, nil
|
||||
}
|
||||
return false, fmt.Errorf("check send restriction: %w", err)
|
||||
}
|
||||
return frozen, nil
|
||||
}
|
||||
|
||||
func scanSendRestriction(row pgx.Row) (domain.AccountSendRestriction, error) {
|
||||
var r domain.AccountSendRestriction
|
||||
func scanAccountFreeze(row pgx.Row) (domain.AccountFreeze, error) {
|
||||
var r domain.AccountFreeze
|
||||
var since, until pgtype.Timestamptz
|
||||
var updated time.Time
|
||||
if err := row.Scan(&r.UserID, &r.Frozen, &r.Reason, &r.Actor, &r.CommandID, &updated); err != nil {
|
||||
return domain.AccountSendRestriction{}, err
|
||||
if err := row.Scan(
|
||||
&r.UserID, &r.Frozen, &since, &until, &r.AppealURL,
|
||||
&r.Reason, &r.Actor, &r.CommandID, &updated,
|
||||
); err != nil {
|
||||
return domain.AccountFreeze{}, err
|
||||
}
|
||||
if since.Valid {
|
||||
r.Since = since.Time
|
||||
}
|
||||
if until.Valid {
|
||||
r.Until = until.Time
|
||||
}
|
||||
r.UpdatedAt = updated
|
||||
return r, nil
|
||||
|
|
|
|||
109
internal/store/postgres/admin_freeze_integration_test.go
Normal file
109
internal/store/postgres/admin_freeze_integration_test.go
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"telesrv/deploy"
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func TestAccountFreezeMigrationAndStoreRoundTrip(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
tx, err := pool.Begin(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
|
||||
downSQL, err := deploy.Migrations.ReadFile("migrations/0088_account_freeze_state.down.sql")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
upSQL, err := deploy.Migrations.ReadFile("migrations/0088_account_freeze_state.up.sql")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, string(downSQL)); err != nil {
|
||||
t.Fatalf("roll schema back to legacy restriction shape: %v", err)
|
||||
}
|
||||
|
||||
const (
|
||||
frozenUserID = int64(1999999881)
|
||||
activeUserID = int64(1999999882)
|
||||
)
|
||||
for _, user := range []struct {
|
||||
id int64
|
||||
phone string
|
||||
}{{frozenUserID, "1999999881"}, {activeUserID, "1999999882"}} {
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO users (id, access_hash, phone, first_name)
|
||||
VALUES ($1, $1, $2, 'Freeze migration test')`, user.id, user.phone); err != nil {
|
||||
t.Fatalf("insert migration user %d: %v", user.id, err)
|
||||
}
|
||||
}
|
||||
legacyUpdatedAt := time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC)
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO account_send_restrictions (user_id, frozen, reason, actor, command_id, updated_at)
|
||||
VALUES ($1, true, 'legacy freeze', 'ops', 'legacy-freeze', $2)`, frozenUserID, legacyUpdatedAt); err != nil {
|
||||
t.Fatalf("insert legacy restriction: %v", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, string(upSQL)); err != nil {
|
||||
t.Fatalf("apply account freeze migration: %v", err)
|
||||
}
|
||||
|
||||
store := NewAdminStore(tx)
|
||||
migrated, found, err := store.GetAccountFreeze(ctx, frozenUserID)
|
||||
if err != nil || !found {
|
||||
t.Fatalf("GetAccountFreeze migrated = %+v found=%v err=%v", migrated, found, err)
|
||||
}
|
||||
if !migrated.Frozen || !migrated.Since.Equal(legacyUpdatedAt) ||
|
||||
!migrated.Until.Equal(legacyUpdatedAt.Add(7*24*time.Hour)) || migrated.AppealURL != "https://t.me/SpamBot" {
|
||||
t.Fatalf("migrated freeze = %+v", migrated)
|
||||
}
|
||||
|
||||
since := time.Date(2026, 7, 15, 2, 0, 0, 0, time.UTC)
|
||||
want := domain.AccountFreeze{
|
||||
UserID: activeUserID,
|
||||
Frozen: true,
|
||||
Since: since,
|
||||
Until: since.Add(48 * time.Hour),
|
||||
AppealURL: "https://appeals.example.test/users/1999999882",
|
||||
Reason: "abuse review",
|
||||
Actor: "ops",
|
||||
CommandID: "freeze-round-trip",
|
||||
}
|
||||
if _, err := store.SetAccountFreeze(ctx, want); err != nil {
|
||||
t.Fatalf("SetAccountFreeze active: %v", err)
|
||||
}
|
||||
got, found, err := store.GetAccountFreeze(ctx, activeUserID)
|
||||
if err != nil || !found || !got.Frozen || !got.Since.Equal(want.Since) ||
|
||||
!got.Until.Equal(want.Until) || got.AppealURL != want.AppealURL {
|
||||
t.Fatalf("active round trip = %+v found=%v err=%v", got, found, err)
|
||||
}
|
||||
if _, err := store.SetAccountFreeze(ctx, domain.AccountFreeze{
|
||||
UserID: activeUserID, Reason: "appeal accepted", Actor: "ops", CommandID: "unfreeze-round-trip",
|
||||
}); err != nil {
|
||||
t.Fatalf("SetAccountFreeze inactive: %v", err)
|
||||
}
|
||||
got, found, err = store.GetAccountFreeze(ctx, activeUserID)
|
||||
if err != nil || !found || got.Frozen || !got.Since.IsZero() || !got.Until.IsZero() || got.AppealURL != "" {
|
||||
t.Fatalf("inactive round trip = %+v found=%v err=%v", got, found, err)
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(ctx, "SAVEPOINT invalid_freeze"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, invalidErr := tx.Exec(ctx, `
|
||||
UPDATE account_restrictions
|
||||
SET frozen = true, frozen_since = NULL, frozen_until = NULL, appeal_url = ''
|
||||
WHERE user_id = $1`, activeUserID)
|
||||
if invalidErr == nil {
|
||||
t.Fatal("database accepted an active freeze without client-visible state")
|
||||
}
|
||||
if _, err := tx.Exec(ctx, "ROLLBACK TO SAVEPOINT invalid_freeze"); err != nil {
|
||||
t.Fatalf("rollback invalid freeze savepoint: %v", err)
|
||||
}
|
||||
}
|
||||
222
internal/store/postgres/auth_identity_lock.go
Normal file
222
internal/store/postgres/auth_identity_lock.go
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
|
||||
"telesrv/internal/store"
|
||||
"telesrv/internal/store/postgres/sqlcgen"
|
||||
)
|
||||
|
||||
const (
|
||||
// authIdentityAdvisoryNamespace is deliberately a two-int advisory-lock
|
||||
// namespace. PostgreSQL keeps it disjoint from the one-bigint advisory locks
|
||||
// used elsewhere in the store. AUTH in ASCII is stable and recognizable in
|
||||
// pg_locks diagnostics.
|
||||
authIdentityAdvisoryNamespace int32 = 0x41555448
|
||||
authIdentityTxMaxAttempts = 3
|
||||
)
|
||||
|
||||
var errAuthIdentityChanged = errors.New("auth key permanent identity changed while acquiring locks")
|
||||
|
||||
type authKeyIdentityHint struct {
|
||||
found bool
|
||||
expiresAt int
|
||||
bound bool
|
||||
permID int64
|
||||
identityID int64
|
||||
hasIdentity bool
|
||||
}
|
||||
|
||||
// withAuthIdentityTx gives identity-sensitive stores an explicit transaction
|
||||
// boundary. Application-level identity visibility changes are retried after a
|
||||
// savepoint/transaction rollback. PostgreSQL deadlock/serialization retries are
|
||||
// only safe when this store owns the top-level transaction; an injected pgx.Tx
|
||||
// is never silently replayed after 40P01/40001.
|
||||
func withAuthIdentityTx(
|
||||
ctx context.Context,
|
||||
db sqlcgen.DBTX,
|
||||
op string,
|
||||
fn func(pgx.Tx) error,
|
||||
) error {
|
||||
_, embedded := db.(pgx.Tx)
|
||||
var lastErr error
|
||||
for attempt := 0; attempt < authIdentityTxMaxAttempts; attempt++ {
|
||||
err := withTx(ctx, db, op, fn)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
lastErr = err
|
||||
switch {
|
||||
case errors.Is(err, errAuthIdentityChanged):
|
||||
// The attempt owns a nested savepoint even for an injected pgx.Tx,
|
||||
// so all row/advisory locks from the stale hint have been released
|
||||
// before the next READ COMMITTED statement snapshot is taken.
|
||||
continue
|
||||
case !embedded && isAuthIdentityRetryableDatabaseError(err):
|
||||
// Defensive retry only. The identity gate is the deadlock fix; this
|
||||
// does not substitute for the global lock order.
|
||||
continue
|
||||
default:
|
||||
return err
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("%s did not stabilize after %d attempts: %w", op, authIdentityTxMaxAttempts, lastErr)
|
||||
}
|
||||
|
||||
func isAuthIdentityRetryableDatabaseError(err error) bool {
|
||||
var pgErr *pgconn.PgError
|
||||
return errors.As(err, &pgErr) && (pgErr.Code == "40P01" || pgErr.Code == "40001")
|
||||
}
|
||||
|
||||
// lockPermanentAuthIdentities acquires the complete batch before any auth-key,
|
||||
// binding, authorization, or update-state row lock. Ordering is by the final
|
||||
// int32 hashint8 key, not by the source bigint identity: hash collisions are
|
||||
// intentionally one lock and cannot create an opposite acquisition order.
|
||||
func lockPermanentAuthIdentities(ctx context.Context, tx pgx.Tx, permIDs []int64) error {
|
||||
if len(permIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
rows, err := tx.Query(ctx, `
|
||||
SELECT DISTINCT hashint8(identity_id)::integer AS lock_key
|
||||
FROM unnest($1::bigint[]) AS identities(identity_id)
|
||||
ORDER BY lock_key`, permIDs)
|
||||
if err != nil {
|
||||
return fmt.Errorf("derive permanent auth identity lock keys: %w", err)
|
||||
}
|
||||
lockKeys := make([]int32, 0, len(permIDs))
|
||||
for rows.Next() {
|
||||
var key int32
|
||||
if err := rows.Scan(&key); err != nil {
|
||||
rows.Close()
|
||||
return fmt.Errorf("scan permanent auth identity lock key: %w", err)
|
||||
}
|
||||
lockKeys = append(lockKeys, key)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
rows.Close()
|
||||
return fmt.Errorf("iterate permanent auth identity lock keys: %w", err)
|
||||
}
|
||||
rows.Close()
|
||||
for _, key := range lockKeys {
|
||||
if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock($1::integer, $2::integer)`, authIdentityAdvisoryNamespace, key); err != nil {
|
||||
return fmt.Errorf("lock permanent auth identity %d: %w", key, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// lookupAuthKeyIdentityHint is intentionally lock-free. A positive-expiry raw
|
||||
// key has no permanent identity until a binding is committed; a permanent raw
|
||||
// key is its own identity. Callers must re-read after the raw row is locked.
|
||||
func lookupAuthKeyIdentityHint(ctx context.Context, tx pgx.Tx, rawID int64) (authKeyIdentityHint, error) {
|
||||
var hint authKeyIdentityHint
|
||||
err := tx.QueryRow(ctx, `
|
||||
/* auth_identity_hint */
|
||||
SELECT key.expires_at,
|
||||
binding.temp_auth_key_id IS NOT NULL,
|
||||
COALESCE(binding.perm_auth_key_id, 0)
|
||||
FROM auth_keys AS key
|
||||
LEFT JOIN temp_auth_key_bindings AS binding
|
||||
ON binding.temp_auth_key_id = key.auth_key_id
|
||||
WHERE key.auth_key_id = $1`, rawID).Scan(&hint.expiresAt, &hint.bound, &hint.permID)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return authKeyIdentityHint{}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return authKeyIdentityHint{}, fmt.Errorf("resolve auth key identity hint: %w", err)
|
||||
}
|
||||
hint.found = true
|
||||
switch {
|
||||
case hint.bound:
|
||||
hint.identityID = hint.permID
|
||||
hint.hasIdentity = true
|
||||
case hint.expiresAt == 0:
|
||||
hint.identityID = rawID
|
||||
hint.hasIdentity = true
|
||||
}
|
||||
return hint, nil
|
||||
}
|
||||
|
||||
// lockRawAuthKeyInIdentityOrder establishes the only cross-identity row-lock
|
||||
// order used by bind, selector advance and direct key deletion:
|
||||
//
|
||||
// permanent identity advisory gate -> raw auth-key row -> permanent row
|
||||
//
|
||||
// If an initially-unbound temp key becomes bound before the raw lock is
|
||||
// acquired, taking its newly discovered identity advisory lock at that point
|
||||
// would recreate raw->identity inversion. The caller must roll back and retry.
|
||||
func lockRawAuthKeyInIdentityOrder(
|
||||
ctx context.Context,
|
||||
tx pgx.Tx,
|
||||
rawID int64,
|
||||
) (rawExpiry int, permID int64, bound bool, err error) {
|
||||
hint, err := lookupAuthKeyIdentityHint(ctx, tx, rawID)
|
||||
if err != nil || !hint.found {
|
||||
if err != nil {
|
||||
return 0, 0, false, err
|
||||
}
|
||||
return 0, 0, false, store.ErrAuthKeyNotFound
|
||||
}
|
||||
if hint.hasIdentity {
|
||||
if err := lockPermanentAuthIdentities(ctx, tx, []int64{hint.identityID}); err != nil {
|
||||
return 0, 0, false, err
|
||||
}
|
||||
}
|
||||
if err := tx.QueryRow(ctx, `
|
||||
SELECT expires_at
|
||||
FROM auth_keys
|
||||
WHERE auth_key_id = $1
|
||||
FOR UPDATE`, rawID).Scan(&rawExpiry); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return 0, 0, false, store.ErrAuthKeyNotFound
|
||||
}
|
||||
return 0, 0, false, fmt.Errorf("lock raw auth key: %w", err)
|
||||
}
|
||||
|
||||
var actualPermID int64
|
||||
err = tx.QueryRow(ctx, `
|
||||
SELECT perm_auth_key_id
|
||||
FROM temp_auth_key_bindings
|
||||
WHERE temp_auth_key_id = $1`, rawID).Scan(&actualPermID)
|
||||
switch {
|
||||
case err == nil:
|
||||
bound = true
|
||||
permID = actualPermID
|
||||
case errors.Is(err, pgx.ErrNoRows):
|
||||
permID = rawID
|
||||
default:
|
||||
return 0, 0, false, fmt.Errorf("revalidate auth key permanent identity: %w", err)
|
||||
}
|
||||
|
||||
actualHasIdentity := bound || rawExpiry == 0
|
||||
actualIdentityID := permID
|
||||
if actualHasIdentity != hint.hasIdentity ||
|
||||
(actualHasIdentity && actualIdentityID != hint.identityID) ||
|
||||
bound != hint.bound || rawExpiry != hint.expiresAt {
|
||||
return 0, 0, false, errAuthIdentityChanged
|
||||
}
|
||||
if !bound {
|
||||
return rawExpiry, permID, false, nil
|
||||
}
|
||||
|
||||
var permExpiry int
|
||||
if err := tx.QueryRow(ctx, `
|
||||
SELECT expires_at
|
||||
FROM auth_keys
|
||||
WHERE auth_key_id = $1
|
||||
FOR UPDATE`, permID).Scan(&permExpiry); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return 0, 0, false, store.ErrAuthKeyBindingInvalid
|
||||
}
|
||||
return 0, 0, false, fmt.Errorf("lock permanent auth key: %w", err)
|
||||
}
|
||||
if rawExpiry <= 0 || permExpiry != 0 {
|
||||
return 0, 0, false, store.ErrAuthKeyBindingInvalid
|
||||
}
|
||||
return rawExpiry, permID, true, nil
|
||||
}
|
||||
429
internal/store/postgres/auth_identity_lock_integration_test.go
Normal file
429
internal/store/postgres/auth_identity_lock_integration_test.go
Normal file
|
|
@ -0,0 +1,429 @@
|
|||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
func TestAuthIdentitySelectorRetriesUncommittedFirstBindSnapshotPostgres(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
keys := NewAuthKeyStore(pool)
|
||||
bindings := NewTempAuthKeyBindingStore(pool)
|
||||
expiresAt := int(time.Now().Add(time.Hour).Unix())
|
||||
temp := saveTempIdentityTestAuthKey(t, ctx, pool, keys, expiresAt)
|
||||
perm := saveTempIdentityTestAuthKey(t, ctx, pool, keys, 0)
|
||||
binding := domain.TempAuthKeyBinding{
|
||||
TempAuthKeyID: temp, PermAuthKeyID: authKeyIDToInt64(perm),
|
||||
Nonce: 8701, TempSessionID: 8702, ExpiresAt: expiresAt,
|
||||
EncryptedMessage: []byte("first bind snapshot"),
|
||||
}
|
||||
|
||||
advanceConn, err := pool.Acquire(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer advanceConn.Release()
|
||||
var advancePID int
|
||||
if err := advanceConn.QueryRow(ctx, `SELECT pg_backend_pid()`).Scan(&advancePID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
barrier := newAuthStoreQueryBarrier(advanceConn, "auth_identity_hint", "")
|
||||
msgID := authKeySessionLayerTestMsgID(time.Now().UTC(), 1)
|
||||
type advanceResult struct {
|
||||
value store.AuthKeySessionLayer
|
||||
applied bool
|
||||
err error
|
||||
}
|
||||
result := make(chan advanceResult, 1)
|
||||
go func() {
|
||||
value, applied, err := NewAuthKeyStore(barrier).AdvanceSessionLayer(ctx, temp, 8703, 227, msgID)
|
||||
result <- advanceResult{value: value, applied: applied, err: err}
|
||||
}()
|
||||
<-barrier.observed
|
||||
|
||||
// The selector already read "unbound". Stage a committed binding behind
|
||||
// its statement snapshot while retaining P/raw row locks in the outer tx.
|
||||
bindTx, err := pool.Begin(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = bindTx.Rollback(context.Background()) }()
|
||||
if err := NewTempAuthKeyBindingStore(bindTx).Save(ctx, binding); err != nil {
|
||||
t.Fatalf("stage first bind: %v", err)
|
||||
}
|
||||
close(barrier.release)
|
||||
waitForPostgresBackendLockWait(t, ctx, pool, advancePID)
|
||||
if err := bindTx.Commit(ctx); err != nil {
|
||||
t.Fatalf("commit first bind: %v", err)
|
||||
}
|
||||
|
||||
got := <-result
|
||||
if got.err != nil || !got.applied || got.value.Layer != 227 || !got.value.SharedDefault {
|
||||
t.Fatalf("advance after identity retry = (%+v,%v,%v)", got.value, got.applied, got.err)
|
||||
}
|
||||
assertTempIdentityBinding(t, ctx, bindings, binding)
|
||||
for _, id := range [][8]byte{temp, perm} {
|
||||
stored, found, err := keys.Get(ctx, id)
|
||||
if err != nil || !found || stored.Layer != 227 || stored.LayerObservationID != got.value.ObservationID {
|
||||
t.Fatalf("shared tuple %x = (%+v,%v,%v)", id, stored, found, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthIdentitySelectorSerializesWithPermanentRevocationAndDeletePostgres(t *testing.T) {
|
||||
for _, op := range []string{"revoke", "delete"} {
|
||||
t.Run(op, func(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
keys := NewAuthKeyStore(pool)
|
||||
auths := NewAuthorizationStore(pool)
|
||||
bindings := NewTempAuthKeyBindingStore(pool)
|
||||
expiresAt := int(time.Now().Add(time.Hour).Unix())
|
||||
temp := saveTempIdentityTestAuthKey(t, ctx, pool, keys, expiresAt)
|
||||
perm := saveTempIdentityTestAuthKey(t, ctx, pool, keys, 0)
|
||||
userID := createRevokeTestUser(t, ctx, pool, "selector-"+op)
|
||||
hash := int64(8800)
|
||||
if err := auths.Bind(ctx, domain.Authorization{AuthKeyID: perm, UserID: userID, Hash: hash}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := bindings.Save(ctx, domain.TempAuthKeyBinding{
|
||||
TempAuthKeyID: temp, PermAuthKeyID: authKeyIDToInt64(perm), ExpiresAt: expiresAt,
|
||||
EncryptedMessage: []byte("identity serialization"),
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
blocker, err := pool.Begin(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = blocker.Rollback(context.Background()) }()
|
||||
if err := lockPermanentAuthIdentities(ctx, blocker, []int64{authKeyIDToInt64(perm)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
selectorConn, err := pool.Acquire(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer selectorConn.Release()
|
||||
opConn, err := pool.Acquire(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer opConn.Release()
|
||||
var selectorPID, opPID int
|
||||
if err := selectorConn.QueryRow(ctx, `SELECT pg_backend_pid()`).Scan(&selectorPID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := opConn.QueryRow(ctx, `SELECT pg_backend_pid()`).Scan(&opPID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
selectorResult := make(chan error, 1)
|
||||
go func() {
|
||||
_, _, err := NewAuthKeyStore(selectorConn).AdvanceSessionLayer(
|
||||
ctx, temp, 8801, 227, authKeySessionLayerTestMsgID(time.Now().UTC(), 1),
|
||||
)
|
||||
selectorResult <- err
|
||||
}()
|
||||
waitForPostgresBackendLockWait(t, ctx, pool, selectorPID)
|
||||
opResult := make(chan error, 1)
|
||||
go func() {
|
||||
if op == "revoke" {
|
||||
_, found, err := NewAuthorizationStore(opConn).RevokeByHash(ctx, userID, hash)
|
||||
if err == nil && !found {
|
||||
err = errors.New("revoke target disappeared")
|
||||
}
|
||||
opResult <- err
|
||||
return
|
||||
}
|
||||
opResult <- NewAuthKeyStore(opConn).Delete(ctx, perm)
|
||||
}()
|
||||
waitForPostgresBackendLockWait(t, ctx, pool, opPID)
|
||||
if err := blocker.Commit(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
selectorErr := <-selectorResult
|
||||
if selectorErr != nil &&
|
||||
!errors.Is(selectorErr, store.ErrAuthKeyNotFound) &&
|
||||
!errors.Is(selectorErr, store.ErrAuthKeyBindingInvalid) {
|
||||
t.Fatalf("selector error = %v", selectorErr)
|
||||
}
|
||||
if err := <-opResult; err != nil {
|
||||
t.Fatalf("%s error = %v", op, err)
|
||||
}
|
||||
assertTempIdentityAuthKeyMissing(t, ctx, keys, temp)
|
||||
assertTempIdentityAuthKeyMissing(t, ctx, keys, perm)
|
||||
assertRevokeTestNoAuthorization(t, ctx, auths, perm)
|
||||
if _, found, err := bindings.GetByTemp(ctx, temp); err != nil || found {
|
||||
t.Fatalf("binding after %s found=%v err=%v", op, found, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthIdentityAuthorizationMirrorUsesLockedPrimaryLayerPostgres(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
keys := NewAuthKeyStore(pool)
|
||||
auths := NewAuthorizationStore(pool)
|
||||
|
||||
t.Run("advance before stale bind", func(t *testing.T) {
|
||||
perm := saveTempIdentityTestAuthKey(t, ctx, pool, keys, 0)
|
||||
userID := createRevokeTestUser(t, ctx, pool, "layer-advance-before-bind")
|
||||
if _, _, err := keys.AdvanceSessionLayer(
|
||||
ctx, perm, 8901, 227, authKeySessionLayerTestMsgID(time.Now().UTC(), 1),
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := auths.Bind(ctx, domain.Authorization{
|
||||
AuthKeyID: perm, UserID: userID, Hash: 8902, Layer: 220,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, found, err := auths.ByAuthKey(ctx, perm)
|
||||
if err != nil || !found || got.Layer != 227 {
|
||||
t.Fatalf("stale bind mirror = (%+v,%v,%v)", got, found, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("bind before advance", func(t *testing.T) {
|
||||
perm := saveTempIdentityTestAuthKey(t, ctx, pool, keys, 0)
|
||||
userID := createRevokeTestUser(t, ctx, pool, "layer-bind-before-advance")
|
||||
if err := auths.Bind(ctx, domain.Authorization{
|
||||
AuthKeyID: perm, UserID: userID, Hash: 8903, Layer: 220,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := keys.AdvanceSessionLayer(
|
||||
ctx, perm, 8904, 227, authKeySessionLayerTestMsgID(time.Now().UTC(), 2),
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, found, err := auths.ByAuthKey(ctx, perm)
|
||||
if err != nil || !found || got.Layer != 227 {
|
||||
t.Fatalf("advanced mirror = (%+v,%v,%v)", got, found, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestDeleteOrphanedRevalidatesUncommittedAuthorizationAndTempBindPostgres(t *testing.T) {
|
||||
t.Run("authorization bind", func(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
keys := NewAuthKeyStore(pool)
|
||||
auths := NewAuthorizationStore(pool)
|
||||
perm := saveTempIdentityTestAuthKey(t, ctx, pool, keys, 0)
|
||||
if _, err := pool.Exec(ctx, `UPDATE auth_keys SET last_used_at = now() - interval '72 hours' WHERE auth_key_id = $1`, authKeyIDToInt64(perm)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
userID := createRevokeTestUser(t, ctx, pool, "orphan-auth-bind")
|
||||
|
||||
gcConn, err := pool.Acquire(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer gcConn.Release()
|
||||
var gcPID int
|
||||
if err := gcConn.QueryRow(ctx, `SELECT pg_backend_pid()`).Scan(&gcPID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
barrier := newAuthStoreQueryBarrier(gcConn, "", "orphan_identity_candidates")
|
||||
gcResult := make(chan struct {
|
||||
deleted int
|
||||
err error
|
||||
}, 1)
|
||||
go func() {
|
||||
deleted, err := NewAuthKeyStore(barrier).DeleteOrphaned(ctx, 24*time.Hour, 1, nil)
|
||||
gcResult <- struct {
|
||||
deleted int
|
||||
err error
|
||||
}{deleted: deleted, err: err}
|
||||
}()
|
||||
<-barrier.observed
|
||||
|
||||
bindTx, err := pool.Begin(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = bindTx.Rollback(context.Background()) }()
|
||||
if err := NewAuthorizationStore(bindTx).Bind(ctx, domain.Authorization{
|
||||
AuthKeyID: perm, UserID: userID, Hash: 9001,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
close(barrier.release)
|
||||
waitForPostgresBackendLockWait(t, ctx, pool, gcPID)
|
||||
if err := bindTx.Commit(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := <-gcResult
|
||||
if got.err != nil || got.deleted != 0 {
|
||||
t.Fatalf("orphan GC after authorization bind = (%d,%v)", got.deleted, got.err)
|
||||
}
|
||||
assertRevokeTestPresentAuthKey(t, ctx, keys, perm)
|
||||
assertRevokeTestPresentAuthorization(t, ctx, auths, perm)
|
||||
})
|
||||
|
||||
t.Run("temporary bind", func(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
keys := NewAuthKeyStore(pool)
|
||||
bindings := NewTempAuthKeyBindingStore(pool)
|
||||
expiresAt := int(time.Now().Add(time.Hour).Unix())
|
||||
temp := saveTempIdentityTestAuthKey(t, ctx, pool, keys, expiresAt)
|
||||
perm := saveTempIdentityTestAuthKey(t, ctx, pool, keys, 0)
|
||||
if _, err := pool.Exec(ctx, `UPDATE auth_keys SET last_used_at = now() - interval '72 hours' WHERE auth_key_id = $1`, authKeyIDToInt64(temp)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
binding := domain.TempAuthKeyBinding{
|
||||
TempAuthKeyID: temp, PermAuthKeyID: authKeyIDToInt64(perm),
|
||||
Nonce: 9002, TempSessionID: 9003, ExpiresAt: expiresAt,
|
||||
EncryptedMessage: []byte("orphan bind revalidation"),
|
||||
}
|
||||
|
||||
gcConn, err := pool.Acquire(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer gcConn.Release()
|
||||
barrier := newAuthStoreQueryBarrier(gcConn, "", "orphan_identity_candidates")
|
||||
gcResult := make(chan struct {
|
||||
deleted int
|
||||
err error
|
||||
}, 1)
|
||||
go func() {
|
||||
deleted, err := NewAuthKeyStore(barrier).DeleteOrphaned(ctx, 24*time.Hour, 1, nil)
|
||||
gcResult <- struct {
|
||||
deleted int
|
||||
err error
|
||||
}{deleted: deleted, err: err}
|
||||
}()
|
||||
<-barrier.observed
|
||||
|
||||
bindTx, err := pool.Begin(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = bindTx.Rollback(context.Background()) }()
|
||||
if err := NewTempAuthKeyBindingStore(bindTx).Save(ctx, binding); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
close(barrier.release)
|
||||
var gc struct {
|
||||
deleted int
|
||||
err error
|
||||
}
|
||||
select {
|
||||
case gc = <-gcResult:
|
||||
case <-time.After(2 * time.Second):
|
||||
_ = bindTx.Commit(context.Background())
|
||||
t.Fatal("orphan GC blocked on an uncommitted temp bind despite SKIP LOCKED")
|
||||
}
|
||||
if gc.err != nil || gc.deleted != 0 {
|
||||
t.Fatalf("orphan GC during temp bind = (%d,%v)", gc.deleted, gc.err)
|
||||
}
|
||||
if err := bindTx.Commit(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertTempIdentityBinding(t, ctx, bindings, binding)
|
||||
assertRevokeTestPresentAuthKey(t, ctx, keys, temp)
|
||||
assertRevokeTestPresentAuthKey(t, ctx, keys, perm)
|
||||
})
|
||||
}
|
||||
|
||||
type authStoreQueryBarrier struct {
|
||||
*pgxpool.Conn
|
||||
queryRowMarker string
|
||||
queryMarker string
|
||||
observed chan struct{}
|
||||
release chan struct{}
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
func newAuthStoreQueryBarrier(conn *pgxpool.Conn, queryRowMarker, queryMarker string) *authStoreQueryBarrier {
|
||||
return &authStoreQueryBarrier{
|
||||
Conn: conn, queryRowMarker: queryRowMarker, queryMarker: queryMarker,
|
||||
observed: make(chan struct{}), release: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (db *authStoreQueryBarrier) Begin(ctx context.Context) (pgx.Tx, error) {
|
||||
tx, err := db.Conn.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &authStoreBarrierTx{Tx: tx, owner: db}, nil
|
||||
}
|
||||
|
||||
type authStoreBarrierTx struct {
|
||||
pgx.Tx
|
||||
owner *authStoreQueryBarrier
|
||||
}
|
||||
|
||||
func (tx *authStoreBarrierTx) QueryRow(ctx context.Context, sql string, args ...any) pgx.Row {
|
||||
row := tx.Tx.QueryRow(ctx, sql, args...)
|
||||
if tx.owner.queryRowMarker != "" && strings.Contains(sql, tx.owner.queryRowMarker) {
|
||||
return &authStoreBarrierRow{Row: row, owner: tx.owner}
|
||||
}
|
||||
return row
|
||||
}
|
||||
|
||||
func (tx *authStoreBarrierTx) Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error) {
|
||||
rows, err := tx.Tx.Query(ctx, sql, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if tx.owner.queryMarker != "" && strings.Contains(sql, tx.owner.queryMarker) {
|
||||
return &authStoreBarrierRows{Rows: rows, owner: tx.owner}, nil
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
type authStoreBarrierRow struct {
|
||||
pgx.Row
|
||||
owner *authStoreQueryBarrier
|
||||
}
|
||||
|
||||
func (row *authStoreBarrierRow) Scan(dest ...any) error {
|
||||
err := row.Row.Scan(dest...)
|
||||
if err == nil {
|
||||
row.owner.once.Do(func() {
|
||||
close(row.owner.observed)
|
||||
<-row.owner.release
|
||||
})
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
type authStoreBarrierRows struct {
|
||||
pgx.Rows
|
||||
owner *authStoreQueryBarrier
|
||||
}
|
||||
|
||||
func (rows *authStoreBarrierRows) Next() bool {
|
||||
next := rows.Rows.Next()
|
||||
if !next {
|
||||
rows.owner.once.Do(func() {
|
||||
close(rows.owner.observed)
|
||||
<-rows.owner.release
|
||||
})
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
|
@ -0,0 +1,471 @@
|
|||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
|
||||
"telesrv/deploy"
|
||||
)
|
||||
|
||||
const (
|
||||
authKeyExpiryMigrationUp = "migrations/0086_auth_key_protocol_expiry.up.sql"
|
||||
authKeyExpiryMigrationDown = "migrations/0086_auth_key_protocol_expiry.down.sql"
|
||||
)
|
||||
|
||||
func TestAuthKeyProtocolExpiryMigrationBackfillAndRollbackPostgres(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
upSQL := readAuthKeyExpiryMigration(t, authKeyExpiryMigrationUp)
|
||||
downSQL := readAuthKeyExpiryMigration(t, authKeyExpiryMigrationDown)
|
||||
|
||||
tx, err := pool.Begin(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("begin auth-key expiry migration test: %v", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback(context.Background()) }()
|
||||
|
||||
if _, err := tx.Exec(ctx, downSQL); err != nil {
|
||||
t.Fatalf("return schema to 0085: %v", err)
|
||||
}
|
||||
|
||||
base := authKeyExpiryMigrationBaseID()
|
||||
tempKeyID := base
|
||||
permKeyID := base - 1
|
||||
authorizedPermKeyID := base - 2
|
||||
unknownKeyID := base - 3
|
||||
userID := base - 4
|
||||
const tempExpiresAt = 1_800_086_000
|
||||
|
||||
for _, authKeyID := range []int64{tempKeyID, permKeyID, authorizedPermKeyID, unknownKeyID} {
|
||||
insertAuthKeyExpiryMigrationKey(t, ctx, tx, authKeyID)
|
||||
}
|
||||
insertAuthKeyExpiryMigrationBinding(t, ctx, tx, tempKeyID, permKeyID, tempExpiresAt)
|
||||
insertAuthKeyExpiryMigrationUser(t, ctx, tx, userID)
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO public.authorizations (auth_key_id, user_id, hash)
|
||||
VALUES ($1, $2, $3)`, authorizedPermKeyID, userID, base-5); err != nil {
|
||||
t.Fatalf("insert authorized permanent key fixture: %v", err)
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(ctx, upSQL); err != nil {
|
||||
t.Fatalf("apply auth-key expiry migration: %v", err)
|
||||
}
|
||||
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
authKeyID int64
|
||||
want int
|
||||
}{
|
||||
{name: "bound temporary", authKeyID: tempKeyID, want: tempExpiresAt},
|
||||
{name: "binding permanent", authKeyID: permKeyID, want: 0},
|
||||
{name: "authorized permanent", authKeyID: authorizedPermKeyID, want: 0},
|
||||
{name: "unclassified legacy", authKeyID: unknownKeyID, want: -1},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
var got int
|
||||
if err := tx.QueryRow(ctx, `SELECT expires_at FROM public.auth_keys WHERE auth_key_id = $1`, test.authKeyID).Scan(&got); err != nil {
|
||||
t.Fatalf("read expires_at: %v", err)
|
||||
}
|
||||
if got != test.want {
|
||||
t.Fatalf("expires_at = %d, want %d", got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
var (
|
||||
indexPredicate string
|
||||
indexValid bool
|
||||
)
|
||||
if err := tx.QueryRow(ctx, `
|
||||
SELECT pg_get_expr(i.indpred, i.indrelid), i.indisvalid
|
||||
FROM pg_catalog.pg_index AS i
|
||||
JOIN pg_catalog.pg_class AS c ON c.oid = i.indexrelid
|
||||
JOIN pg_catalog.pg_namespace AS n ON n.oid = c.relnamespace
|
||||
WHERE n.nspname = 'public'
|
||||
AND c.relname = 'auth_keys_temporary_expiry_seek_idx'`).Scan(&indexPredicate, &indexValid); err != nil {
|
||||
t.Fatalf("inspect temporary expiry partial index: %v", err)
|
||||
}
|
||||
if !indexValid || !strings.Contains(indexPredicate, "expires_at > 0") {
|
||||
t.Fatalf("temporary expiry index valid=%v predicate=%q, want valid partial expires_at > 0", indexValid, indexPredicate)
|
||||
}
|
||||
|
||||
var (
|
||||
deleteAction string
|
||||
fkValidated bool
|
||||
)
|
||||
if err := tx.QueryRow(ctx, `
|
||||
SELECT c.confdeltype::text, c.convalidated
|
||||
FROM pg_catalog.pg_constraint AS c
|
||||
WHERE c.conrelid = 'public.temp_auth_key_bindings'::regclass
|
||||
AND c.conname = 'temp_auth_key_bindings_perm_auth_key_id_fkey'`).Scan(&deleteAction, &fkValidated); err != nil {
|
||||
t.Fatalf("inspect permanent auth-key FK: %v", err)
|
||||
}
|
||||
if deleteAction != "r" || !fkValidated {
|
||||
t.Fatalf("permanent auth-key FK delete action=%q validated=%v, want RESTRICT/true", deleteAction, fkValidated)
|
||||
}
|
||||
|
||||
assertAuthKeyExpiryMigrationForeignKeyViolation(t, ctx, tx, func(nested pgx.Tx) error {
|
||||
_, err := nested.Exec(ctx, `DELETE FROM public.auth_keys WHERE auth_key_id = $1`, permKeyID)
|
||||
return err
|
||||
})
|
||||
assertAuthKeyExpiryMigrationForeignKeyViolation(t, ctx, tx, func(nested pgx.Tx) error {
|
||||
_, err := nested.Exec(ctx, `
|
||||
INSERT INTO public.temp_auth_key_bindings (
|
||||
temp_auth_key_id, perm_auth_key_id, nonce, expires_at, encrypted_message, temp_session_id
|
||||
) VALUES ($1, $2, 86, $3, decode('86', 'hex'), 86)`, unknownKeyID, base-86, tempExpiresAt)
|
||||
return err
|
||||
})
|
||||
|
||||
if _, err := tx.Exec(ctx, downSQL); err != nil {
|
||||
t.Fatalf("roll back auth-key expiry migration: %v", err)
|
||||
}
|
||||
|
||||
var (
|
||||
expiresColumnExists bool
|
||||
expiryIndexExists bool
|
||||
permFKExists bool
|
||||
fixtureKeyCount int
|
||||
)
|
||||
if err := tx.QueryRow(ctx, `
|
||||
SELECT
|
||||
EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = 'public' AND table_name = 'auth_keys' AND column_name = 'expires_at'
|
||||
),
|
||||
to_regclass('public.auth_keys_temporary_expiry_seek_idx') IS NOT NULL,
|
||||
EXISTS (
|
||||
SELECT 1 FROM pg_catalog.pg_constraint
|
||||
WHERE conrelid = 'public.temp_auth_key_bindings'::regclass
|
||||
AND conname = 'temp_auth_key_bindings_perm_auth_key_id_fkey'
|
||||
),
|
||||
(SELECT count(*) FROM public.auth_keys WHERE auth_key_id = ANY($1::bigint[]))
|
||||
`, []int64{tempKeyID, permKeyID, authorizedPermKeyID, unknownKeyID}).Scan(
|
||||
&expiresColumnExists,
|
||||
&expiryIndexExists,
|
||||
&permFKExists,
|
||||
&fixtureKeyCount,
|
||||
); err != nil {
|
||||
t.Fatalf("inspect 0086 down result: %v", err)
|
||||
}
|
||||
if expiresColumnExists || expiryIndexExists || permFKExists || fixtureKeyCount != 4 {
|
||||
t.Fatalf("0086 down result column=%v index=%v fk=%v keys=%d, want false/false/false/4", expiresColumnExists, expiryIndexExists, permFKExists, fixtureKeyCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthKeyProtocolExpiryMigrationRejectsInvalidIdentityStatePostgres(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
upSQL := readAuthKeyExpiryMigration(t, authKeyExpiryMigrationUp)
|
||||
downSQL := readAuthKeyExpiryMigration(t, authKeyExpiryMigrationDown)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
wantMessage string
|
||||
setup func(*testing.T, context.Context, pgx.Tx, int64)
|
||||
}{
|
||||
{
|
||||
name: "nonpositive binding expiry",
|
||||
wantMessage: "invalid non-positive temporary auth key expiry",
|
||||
setup: func(t *testing.T, ctx context.Context, tx pgx.Tx, base int64) {
|
||||
insertAuthKeyExpiryMigrationKey(t, ctx, tx, base)
|
||||
insertAuthKeyExpiryMigrationKey(t, ctx, tx, base-1)
|
||||
insertAuthKeyExpiryMigrationBinding(t, ctx, tx, base, base-1, 0)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "dangling permanent key",
|
||||
wantMessage: "temporary auth key binding references missing permanent key",
|
||||
setup: func(t *testing.T, ctx context.Context, tx pgx.Tx, base int64) {
|
||||
insertAuthKeyExpiryMigrationKey(t, ctx, tx, base)
|
||||
insertAuthKeyExpiryMigrationBinding(t, ctx, tx, base, base-1, 1_800_086_001)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "self binding",
|
||||
wantMessage: "temporary auth key self-binding",
|
||||
setup: func(t *testing.T, ctx context.Context, tx pgx.Tx, base int64) {
|
||||
insertAuthKeyExpiryMigrationKey(t, ctx, tx, base)
|
||||
insertAuthKeyExpiryMigrationBinding(t, ctx, tx, base, base, 1_800_086_002)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "temporary and permanent role overlap",
|
||||
wantMessage: "auth key appears in both temporary and permanent roles",
|
||||
setup: func(t *testing.T, ctx context.Context, tx pgx.Tx, base int64) {
|
||||
for _, authKeyID := range []int64{base, base - 1, base - 2} {
|
||||
insertAuthKeyExpiryMigrationKey(t, ctx, tx, authKeyID)
|
||||
}
|
||||
insertAuthKeyExpiryMigrationBinding(t, ctx, tx, base, base-1, 1_800_086_003)
|
||||
insertAuthKeyExpiryMigrationBinding(t, ctx, tx, base-1, base-2, 1_800_086_004)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "authorization on bound temporary key",
|
||||
wantMessage: "invalid authorization on temporary auth key",
|
||||
setup: func(t *testing.T, ctx context.Context, tx pgx.Tx, base int64) {
|
||||
insertAuthKeyExpiryMigrationKey(t, ctx, tx, base)
|
||||
insertAuthKeyExpiryMigrationKey(t, ctx, tx, base-1)
|
||||
insertAuthKeyExpiryMigrationBinding(t, ctx, tx, base, base-1, 1_800_086_005)
|
||||
insertAuthKeyExpiryMigrationUser(t, ctx, tx, base-2)
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO public.authorizations (auth_key_id, user_id, hash)
|
||||
VALUES ($1, $2, $3)`, base, base-2, base-3); err != nil {
|
||||
t.Fatalf("insert temporary-key authorization fixture: %v", err)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "update state on bound temporary key",
|
||||
wantMessage: "update state references temporary auth key",
|
||||
setup: func(t *testing.T, ctx context.Context, tx pgx.Tx, base int64) {
|
||||
insertAuthKeyExpiryMigrationKey(t, ctx, tx, base)
|
||||
insertAuthKeyExpiryMigrationKey(t, ctx, tx, base-1)
|
||||
insertAuthKeyExpiryMigrationBinding(t, ctx, tx, base, base-1, 1_800_086_006)
|
||||
insertAuthKeyExpiryMigrationUser(t, ctx, tx, base-2)
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO public.update_states (auth_key_id, user_id, pts, date)
|
||||
VALUES ($1, $2, 1, 1)`, base, base-2); err != nil {
|
||||
t.Fatalf("insert temporary-key update state fixture: %v", err)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "bootstrap update job on bound temporary key",
|
||||
wantMessage: "bootstrap update job references temporary auth key",
|
||||
setup: func(t *testing.T, ctx context.Context, tx pgx.Tx, base int64) {
|
||||
insertAuthKeyExpiryMigrationBoundPair(t, ctx, tx, base, base-1, 1_800_086_007)
|
||||
userID := base - 2
|
||||
messageID := base - 3
|
||||
insertAuthKeyExpiryMigrationUser(t, ctx, tx, userID)
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO public.private_messages (
|
||||
id, sender_user_id, recipient_user_id, message_date, body
|
||||
) VALUES ($1, $2, $2, 1, 'migration-0086')`, messageID, userID); err != nil {
|
||||
t.Fatalf("insert bootstrap private message fixture: %v", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO public.message_boxes (
|
||||
owner_user_id, box_id, private_message_id, message_sender_id,
|
||||
peer_type, peer_id, from_user_id, message_date, body
|
||||
) VALUES ($1, 860086, $2, $1, 'user', $1, $1, 1, 'migration-0086')`, userID, messageID); err != nil {
|
||||
t.Fatalf("insert bootstrap message box fixture: %v", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO public.bootstrap_update_jobs (
|
||||
kind, user_id, auth_key_id, session_id, message_box_id
|
||||
) VALUES ('login_message', $1, $2, 86, 860086)`, userID, base); err != nil {
|
||||
t.Fatalf("insert temporary-key bootstrap job fixture: %v", err)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "secret qts watermark on bound temporary key",
|
||||
wantMessage: "secret qts watermark references temporary auth key",
|
||||
setup: func(t *testing.T, ctx context.Context, tx pgx.Tx, base int64) {
|
||||
insertAuthKeyExpiryMigrationBoundPair(t, ctx, tx, base, base-1, 1_800_086_008)
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO public.secret_qts_watermarks (auth_key_id, reserved_qts, confirmed_qts)
|
||||
VALUES ($1, 1, 1)`, base); err != nil {
|
||||
t.Fatalf("insert temporary-key secret qts watermark fixture: %v", err)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "encrypted message queue on bound temporary key",
|
||||
wantMessage: "encrypted message queue references temporary auth key",
|
||||
setup: func(t *testing.T, ctx context.Context, tx pgx.Tx, base int64) {
|
||||
insertAuthKeyExpiryMigrationBoundPair(t, ctx, tx, base, base-1, 1_800_086_009)
|
||||
userID := base - 2
|
||||
insertAuthKeyExpiryMigrationUser(t, ctx, tx, userID)
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO public.encrypted_message_queue (
|
||||
receiver_auth_key_id, qts, receiver_user_id, chat_id, random_id, date, bytes
|
||||
) VALUES ($1, 1, $2, 860086, $3, 1, decode('86', 'hex'))`, base, userID, base-3); err != nil {
|
||||
t.Fatalf("insert temporary-key encrypted message queue fixture: %v", err)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "encrypted state delivery on bound temporary key",
|
||||
wantMessage: "encrypted state delivery references temporary auth key",
|
||||
setup: func(t *testing.T, ctx context.Context, tx pgx.Tx, base int64) {
|
||||
insertAuthKeyExpiryMigrationBoundPair(t, ctx, tx, base, base-1, 1_800_086_010)
|
||||
userID := base - 2
|
||||
eventID := base - 3
|
||||
insertAuthKeyExpiryMigrationUser(t, ctx, tx, userID)
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO public.encrypted_state_events (
|
||||
id, target_user_id, target_auth_key_id, chat_id, event_type, date
|
||||
) VALUES ($1, $2, $3, 860086, 1, 1)`, eventID, userID, base-1); err != nil {
|
||||
t.Fatalf("insert permanent-key encrypted state event fixture: %v", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO public.encrypted_state_event_delivery (event_id, auth_key_id)
|
||||
VALUES ($1, $2)`, eventID, base); err != nil {
|
||||
t.Fatalf("insert temporary-key encrypted state delivery fixture: %v", err)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "encrypted state event on bound temporary key",
|
||||
wantMessage: "encrypted state event targets temporary auth key",
|
||||
setup: func(t *testing.T, ctx context.Context, tx pgx.Tx, base int64) {
|
||||
insertAuthKeyExpiryMigrationBoundPair(t, ctx, tx, base, base-1, 1_800_086_011)
|
||||
userID := base - 2
|
||||
insertAuthKeyExpiryMigrationUser(t, ctx, tx, userID)
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO public.encrypted_state_events (
|
||||
id, target_user_id, target_auth_key_id, chat_id, event_type, date
|
||||
) VALUES ($1, $2, $3, 860086, 1, 1)`, base-3, userID, base); err != nil {
|
||||
t.Fatalf("insert temporary-key encrypted state event fixture: %v", err)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "secret chat admin on bound temporary key",
|
||||
wantMessage: "secret chat references temporary auth key",
|
||||
setup: func(t *testing.T, ctx context.Context, tx pgx.Tx, base int64) {
|
||||
insertAuthKeyExpiryMigrationBoundPair(t, ctx, tx, base, base-1, 1_800_086_012)
|
||||
adminUserID := base - 2
|
||||
participantUserID := base - 3
|
||||
insertAuthKeyExpiryMigrationUser(t, ctx, tx, adminUserID)
|
||||
insertAuthKeyExpiryMigrationUser(t, ctx, tx, participantUserID)
|
||||
insertAuthKeyExpiryMigrationSecretChat(t, ctx, tx, base, base-1, adminUserID, participantUserID)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "secret chat participant on bound temporary key",
|
||||
wantMessage: "secret chat references temporary auth key",
|
||||
setup: func(t *testing.T, ctx context.Context, tx pgx.Tx, base int64) {
|
||||
insertAuthKeyExpiryMigrationBoundPair(t, ctx, tx, base, base-1, 1_800_086_013)
|
||||
adminUserID := base - 2
|
||||
participantUserID := base - 3
|
||||
insertAuthKeyExpiryMigrationUser(t, ctx, tx, adminUserID)
|
||||
insertAuthKeyExpiryMigrationUser(t, ctx, tx, participantUserID)
|
||||
insertAuthKeyExpiryMigrationSecretChat(t, ctx, tx, base-1, base, adminUserID, participantUserID)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for i, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
tx, err := pool.Begin(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("begin invalid-state migration test: %v", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback(context.Background()) }()
|
||||
|
||||
if _, err := tx.Exec(ctx, downSQL); err != nil {
|
||||
t.Fatalf("return schema to 0085: %v", err)
|
||||
}
|
||||
test.setup(t, ctx, tx, authKeyExpiryMigrationBaseID()-int64(i*100))
|
||||
|
||||
_, err = tx.Exec(ctx, upSQL)
|
||||
if err == nil {
|
||||
t.Fatal("0086 migration accepted invalid identity state")
|
||||
}
|
||||
var pgErr *pgconn.PgError
|
||||
if !errors.As(err, &pgErr) || pgErr.Code != "P0001" || !strings.Contains(pgErr.Message, test.wantMessage) {
|
||||
t.Fatalf("0086 migration error = %v, want SQLSTATE P0001 containing %q", err, test.wantMessage)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func readAuthKeyExpiryMigration(t *testing.T, name string) string {
|
||||
t.Helper()
|
||||
sql, err := deploy.Migrations.ReadFile(name)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", name, err)
|
||||
}
|
||||
return string(sql)
|
||||
}
|
||||
|
||||
func authKeyExpiryMigrationBaseID() int64 {
|
||||
return -(time.Now().UnixNano() & 0x3fffffffffffffff)
|
||||
}
|
||||
|
||||
func insertAuthKeyExpiryMigrationKey(t *testing.T, ctx context.Context, tx pgx.Tx, authKeyID int64) {
|
||||
t.Helper()
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO public.auth_keys (auth_key_id, body, server_salt)
|
||||
VALUES ($1, decode('86', 'hex'), 86)`, authKeyID); err != nil {
|
||||
t.Fatalf("insert auth key %d: %v", authKeyID, err)
|
||||
}
|
||||
}
|
||||
|
||||
func insertAuthKeyExpiryMigrationBinding(t *testing.T, ctx context.Context, tx pgx.Tx, tempKeyID, permKeyID int64, expiresAt int) {
|
||||
t.Helper()
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO public.temp_auth_key_bindings (
|
||||
temp_auth_key_id, perm_auth_key_id, nonce, expires_at, encrypted_message, temp_session_id
|
||||
) VALUES ($1, $2, 86, $3, decode('86', 'hex'), 86)`, tempKeyID, permKeyID, expiresAt); err != nil {
|
||||
t.Fatalf("insert temp auth-key binding %d -> %d: %v", tempKeyID, permKeyID, err)
|
||||
}
|
||||
}
|
||||
|
||||
func insertAuthKeyExpiryMigrationBoundPair(t *testing.T, ctx context.Context, tx pgx.Tx, tempKeyID, permKeyID int64, expiresAt int) {
|
||||
t.Helper()
|
||||
insertAuthKeyExpiryMigrationKey(t, ctx, tx, tempKeyID)
|
||||
insertAuthKeyExpiryMigrationKey(t, ctx, tx, permKeyID)
|
||||
insertAuthKeyExpiryMigrationBinding(t, ctx, tx, tempKeyID, permKeyID, expiresAt)
|
||||
}
|
||||
|
||||
func insertAuthKeyExpiryMigrationUser(t *testing.T, ctx context.Context, tx pgx.Tx, userID int64) {
|
||||
t.Helper()
|
||||
phone := fmt.Sprintf("+860086%d", -userID)
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO public.users (id, access_hash, phone, first_name)
|
||||
VALUES ($1, $2, $3, 'migration-0086')`, userID, userID-1, phone); err != nil {
|
||||
t.Fatalf("insert migration user %d: %v", userID, err)
|
||||
}
|
||||
}
|
||||
|
||||
func insertAuthKeyExpiryMigrationSecretChat(
|
||||
t *testing.T,
|
||||
ctx context.Context,
|
||||
tx pgx.Tx,
|
||||
adminAuthKeyID int64,
|
||||
participantAuthKeyID int64,
|
||||
adminUserID int64,
|
||||
participantUserID int64,
|
||||
) {
|
||||
t.Helper()
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO public.secret_chats (
|
||||
chat_id, admin_access_hash, participant_access_hash,
|
||||
admin_user_id, admin_auth_key_id, participant_user_id, participant_auth_key_id,
|
||||
state, random_id, date
|
||||
) VALUES (
|
||||
860086, 86, 87,
|
||||
$1, $2, $3, $4,
|
||||
'waiting', 86, 1
|
||||
)`, adminUserID, adminAuthKeyID, participantUserID, participantAuthKeyID); err != nil {
|
||||
t.Fatalf("insert temporary-key secret chat fixture: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertAuthKeyExpiryMigrationForeignKeyViolation(t *testing.T, ctx context.Context, tx pgx.Tx, action func(pgx.Tx) error) {
|
||||
t.Helper()
|
||||
nested, err := tx.Begin(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("begin FK assertion savepoint: %v", err)
|
||||
}
|
||||
defer func() { _ = nested.Rollback(context.Background()) }()
|
||||
|
||||
err = action(nested)
|
||||
if err == nil {
|
||||
t.Fatal("operation bypassed permanent auth-key RESTRICT FK")
|
||||
}
|
||||
var pgErr *pgconn.PgError
|
||||
if !errors.As(err, &pgErr) || pgErr.Code != "23503" || pgErr.ConstraintName != "temp_auth_key_bindings_perm_auth_key_id_fkey" {
|
||||
t.Fatalf("FK error = %v, want SQLSTATE 23503 from temp_auth_key_bindings_perm_auth_key_id_fkey", err)
|
||||
}
|
||||
}
|
||||
|
|
@ -28,14 +28,24 @@ func NewAuthKeyStore(db sqlcgen.DBTX) *AuthKeyStore {
|
|||
// Save 实现 store.AuthKeyStore。auth_key_id 以小端解释为 int64 存入 BIGINT;
|
||||
// created_at/last_used_at 交由 DB 默认值(now()),故传入的 CreatedAt 不落库。
|
||||
func (s *AuthKeyStore) Save(ctx context.Context, k store.AuthKeyData) error {
|
||||
if _, err := s.db.Exec(ctx, `
|
||||
INSERT INTO auth_keys (auth_key_id, body, server_salt)
|
||||
VALUES ($1, $2, $3)
|
||||
if !store.ValidNewAuthKeyProtocolExpiry(k.ExpiresAt) {
|
||||
return store.ErrInvalidAuthKeyProtocolExpiry
|
||||
}
|
||||
tag, err := s.db.Exec(ctx, `
|
||||
INSERT INTO auth_keys (auth_key_id, body, server_salt, expires_at)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (auth_key_id) DO UPDATE
|
||||
SET body = EXCLUDED.body, server_salt = EXCLUDED.server_salt, last_used_at = now()
|
||||
`, authKeyIDToInt64(k.ID), k.Value[:], k.ServerSalt); err != nil {
|
||||
SET server_salt = EXCLUDED.server_salt,
|
||||
last_used_at = now()
|
||||
WHERE auth_keys.body = EXCLUDED.body
|
||||
AND auth_keys.expires_at = EXCLUDED.expires_at
|
||||
`, authKeyIDToInt64(k.ID), k.Value[:], k.ServerSalt, k.ExpiresAt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("upsert auth key: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() != 1 {
|
||||
return store.ErrAuthKeyProtocolMetadataConflict
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
@ -45,23 +55,26 @@ SET body = EXCLUDED.body, server_salt = EXCLUDED.server_salt, last_used_at = now
|
|||
// 注册进 SessionManager”的窗口被后台清理。
|
||||
func (s *AuthKeyStore) Get(ctx context.Context, id [8]byte) (store.AuthKeyData, bool, error) {
|
||||
var (
|
||||
body []byte
|
||||
serverSalt int64
|
||||
createdAt pgtype.Timestamptz
|
||||
layer int
|
||||
deviceModel string
|
||||
platform string
|
||||
systemVersion string
|
||||
apiID int
|
||||
appVersion string
|
||||
body []byte
|
||||
serverSalt int64
|
||||
expiresAt int
|
||||
createdAt pgtype.Timestamptz
|
||||
layer int
|
||||
layerObservationID int64
|
||||
deviceModel string
|
||||
platform string
|
||||
systemVersion string
|
||||
apiID int
|
||||
appVersion string
|
||||
)
|
||||
err := s.db.QueryRow(ctx, `
|
||||
UPDATE auth_keys
|
||||
SET last_used_at = now()
|
||||
WHERE auth_key_id = $1
|
||||
RETURNING auth_key_id, body, server_salt, created_at,
|
||||
layer, device_model, platform, system_version, api_id, app_version
|
||||
`, authKeyIDToInt64(id)).Scan(new(int64), &body, &serverSalt, &createdAt, &layer, &deviceModel, &platform, &systemVersion, &apiID, &appVersion)
|
||||
expires_at, layer, layer_observation_id,
|
||||
device_model, platform, system_version, api_id, app_version
|
||||
`, authKeyIDToInt64(id)).Scan(new(int64), &body, &serverSalt, &createdAt, &expiresAt, &layer, &layerObservationID, &deviceModel, &platform, &systemVersion, &apiID, &appVersion)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return store.AuthKeyData{}, false, nil
|
||||
|
|
@ -72,14 +85,16 @@ RETURNING auth_key_id, body, server_salt, created_at,
|
|||
return store.AuthKeyData{}, false, fmt.Errorf("auth key body length = %d, want 256", len(body))
|
||||
}
|
||||
data := store.AuthKeyData{
|
||||
ID: id,
|
||||
ServerSalt: serverSalt,
|
||||
Layer: layer,
|
||||
DeviceModel: deviceModel,
|
||||
Platform: platform,
|
||||
SystemVersion: systemVersion,
|
||||
APIID: apiID,
|
||||
AppVersion: appVersion,
|
||||
ID: id,
|
||||
ServerSalt: serverSalt,
|
||||
ExpiresAt: expiresAt,
|
||||
Layer: layer,
|
||||
LayerObservationID: layerObservationID,
|
||||
DeviceModel: deviceModel,
|
||||
Platform: platform,
|
||||
SystemVersion: systemVersion,
|
||||
APIID: apiID,
|
||||
AppVersion: appVersion,
|
||||
}
|
||||
copy(data.Value[:], body)
|
||||
if createdAt.Valid {
|
||||
|
|
@ -135,7 +150,8 @@ WHERE auth_key_id = ANY($1::bigint[])`, batch)
|
|||
}
|
||||
|
||||
func (s *AuthKeyStore) UpdateClientInfo(ctx context.Context, id [8]byte, info store.AuthKeyClientInfo) error {
|
||||
if _, err := s.db.Exec(ctx, `
|
||||
var updated int
|
||||
err := s.db.QueryRow(ctx, `
|
||||
UPDATE auth_keys
|
||||
SET layer = CASE WHEN $2::integer > 0 THEN $2 ELSE layer END,
|
||||
device_model = CASE WHEN $3::text <> '' THEN $3 ELSE device_model END,
|
||||
|
|
@ -144,7 +160,31 @@ SET layer = CASE WHEN $2::integer > 0 THEN $2 ELSE layer END,
|
|||
api_id = CASE WHEN $6::integer <> 0 THEN $6 ELSE api_id END,
|
||||
app_version = CASE WHEN $7::text <> '' THEN $7 ELSE app_version END
|
||||
WHERE auth_key_id = $1
|
||||
`, authKeyIDToInt64(id), info.Layer, info.DeviceModel, info.Platform, info.SystemVersion, info.APIID, info.AppVersion); err != nil {
|
||||
AND ($2::integer <= 0 OR layer_observation_id = 0 OR layer = $2::integer)
|
||||
RETURNING 1
|
||||
`, authKeyIDToInt64(id), info.Layer, info.DeviceModel, info.Platform, info.SystemVersion, info.APIID, info.AppVersion).Scan(&updated)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
var (
|
||||
currentLayer int
|
||||
observation int64
|
||||
)
|
||||
lookupErr := s.db.QueryRow(ctx, `
|
||||
SELECT layer, layer_observation_id
|
||||
FROM auth_keys
|
||||
WHERE auth_key_id = $1
|
||||
`, authKeyIDToInt64(id)).Scan(¤tLayer, &observation)
|
||||
switch {
|
||||
case errors.Is(lookupErr, pgx.ErrNoRows):
|
||||
return store.ErrAuthKeyNotFound
|
||||
case lookupErr != nil:
|
||||
return fmt.Errorf("classify auth key client info update: %w", lookupErr)
|
||||
case info.Layer > 0 && observation > 0 && currentLayer != info.Layer:
|
||||
return store.ErrAuthKeySessionLayerConflict
|
||||
default:
|
||||
return fmt.Errorf("update auth key client info: guarded update affected no row")
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("update auth key client info: %w", err)
|
||||
}
|
||||
return nil
|
||||
|
|
@ -154,13 +194,24 @@ WHERE auth_key_id = $1
|
|||
// 手写 SQL 而非 sqlc 生成:避免触碰 sqlcgen 再生成链路。
|
||||
//
|
||||
// 同时清理把本 key 当作 perm key 的 temp auth key 行:temp_auth_key_bindings.temp_auth_key_id
|
||||
// 侧有外键 ON DELETE CASCADE,删除 temp key 会自动清绑定;perm_auth_key_id 列无外键,
|
||||
// 因此被踢/登出删除 perm key 时必须先把关联 temp key 一并删掉。否则 Web/上传连接用
|
||||
// 侧有外键 ON DELETE CASCADE,删除 temp key 会自动清绑定;perm_auth_key_id 侧由
|
||||
// RESTRICT FK 防止悬空,因此被踢/销毁 perm key 时必须先把关联 temp key 一并删掉。否则 Web/上传连接用
|
||||
// raw temp key 重连时仍能进入 RPC 层,只得到 AUTH_KEY_UNREGISTERED,而不是连接层 404。
|
||||
func (s *AuthKeyStore) Delete(ctx context.Context, id [8]byte) error {
|
||||
keyID := authKeyIDToInt64(id)
|
||||
return withAuthIdentityTx(ctx, s.db, "delete auth key", func(tx pgx.Tx) error {
|
||||
return deleteAuthKeyTx(ctx, tx, authKeyIDToInt64(id))
|
||||
})
|
||||
}
|
||||
|
||||
func deleteAuthKeyTx(ctx context.Context, tx pgx.Tx, keyID int64) error {
|
||||
if _, _, _, err := lockRawAuthKeyInIdentityOrder(ctx, tx, keyID); err != nil {
|
||||
if errors.Is(err, store.ErrAuthKeyNotFound) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
var touched int
|
||||
if err := s.db.QueryRow(ctx, `
|
||||
if err := tx.QueryRow(ctx, `
|
||||
WITH doomed_temp AS MATERIALIZED (
|
||||
SELECT temp_auth_key_id
|
||||
FROM temp_auth_key_bindings
|
||||
|
|
@ -177,17 +228,25 @@ WITH doomed_temp AS MATERIALIZED (
|
|||
RETURNING auth_key_id
|
||||
), deleted_temp AS (
|
||||
DELETE FROM auth_keys
|
||||
WHERE auth_key_id IN (SELECT auth_key_id FROM doomed_keys)
|
||||
WHERE auth_key_id IN (SELECT temp_auth_key_id FROM doomed_temp)
|
||||
RETURNING auth_key_id
|
||||
), deleted_key AS (
|
||||
DELETE FROM auth_keys
|
||||
WHERE auth_key_id = $1
|
||||
AND (SELECT count(*) FROM deleted_temp) >= 0
|
||||
RETURNING auth_key_id
|
||||
)
|
||||
SELECT
|
||||
(SELECT count(*) FROM deleted_update_states)::int +
|
||||
(SELECT count(*) FROM deleted_temp)::int`, keyID).Scan(&touched); err != nil {
|
||||
(SELECT count(*) FROM deleted_temp)::int +
|
||||
(SELECT count(*) FROM deleted_key)::int`, keyID).Scan(&touched); err != nil {
|
||||
return fmt.Errorf("delete auth key and temp bindings: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
const tempAuthKeyPermFKConstraint = "temp_auth_key_bindings_perm_auth_key_id_fkey"
|
||||
|
||||
// DeleteOrphaned 回收握手已落库、但从未形成 authorization/temp binding 且当前没有
|
||||
// 活跃物理连接的旧 auth key。last_used_at 与 Get 的 UPDATE ... RETURNING 行锁配对,封住
|
||||
// active-key 快照之后新连接开始使用旧 key 的竞态;所有引用条件仍在最终 DELETE 中复核。
|
||||
|
|
@ -204,51 +263,140 @@ func (s *AuthKeyStore) DeleteOrphaned(ctx context.Context, olderThan time.Durati
|
|||
protectedIDs = append(protectedIDs, authKeyIDToInt64(id))
|
||||
}
|
||||
var deleted int
|
||||
err := s.db.QueryRow(ctx, `
|
||||
WITH candidates AS MATERIALIZED (
|
||||
err := withAuthIdentityTx(ctx, s.db, "delete orphaned auth keys", func(tx pgx.Tx) error {
|
||||
var err error
|
||||
deleted, err = deleteOrphanedAuthKeysTx(ctx, tx, olderThan, limit, protectedIDs)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("delete orphaned auth keys: %w", err)
|
||||
}
|
||||
return deleted, nil
|
||||
}
|
||||
|
||||
func deleteOrphanedAuthKeysTx(
|
||||
ctx context.Context,
|
||||
tx pgx.Tx,
|
||||
olderThan time.Duration,
|
||||
limit int,
|
||||
protectedIDs []int64,
|
||||
) (int, error) {
|
||||
// Phase 1 is only a bounded hint. It must not lock rows before the complete
|
||||
// permanent-identity advisory set has been derived and acquired.
|
||||
rows, err := tx.Query(ctx, `
|
||||
/* orphan_identity_candidates */
|
||||
SELECT k.auth_key_id, k.expires_at
|
||||
FROM auth_keys AS k
|
||||
WHERE k.last_used_at < now() - make_interval(secs => $1::double precision)
|
||||
AND NOT (k.auth_key_id = ANY($2::bigint[]))
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM authorizations AS a WHERE a.auth_key_id = k.auth_key_id
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM temp_auth_key_bindings AS b
|
||||
WHERE b.temp_auth_key_id = k.auth_key_id OR b.perm_auth_key_id = k.auth_key_id
|
||||
)
|
||||
ORDER BY k.last_used_at, k.auth_key_id
|
||||
LIMIT $3`, olderThan.Seconds(), protectedIDs, limit)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("select orphan auth key candidates: %w", err)
|
||||
}
|
||||
candidates := make([]int64, 0, limit)
|
||||
permanentCandidates := make([]int64, 0, limit)
|
||||
for rows.Next() {
|
||||
var (
|
||||
id int64
|
||||
expiresAt int
|
||||
)
|
||||
if err := rows.Scan(&id, &expiresAt); err != nil {
|
||||
rows.Close()
|
||||
return 0, fmt.Errorf("scan orphan auth key candidate: %w", err)
|
||||
}
|
||||
candidates = append(candidates, id)
|
||||
if expiresAt == 0 {
|
||||
permanentCandidates = append(permanentCandidates, id)
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
rows.Close()
|
||||
return 0, fmt.Errorf("iterate orphan auth key candidates: %w", err)
|
||||
}
|
||||
rows.Close()
|
||||
if len(candidates) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
if err := lockPermanentAuthIdentities(ctx, tx, permanentCandidates); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
// Phase 2 locks only the hinted raw rows, in real-ID order, after every P
|
||||
// gate is held. A temp bind already holding its raw row is skipped; if it
|
||||
// committed immediately before this lock, phase 3's new READ COMMITTED
|
||||
// statement sees the binding and excludes it.
|
||||
lockRows, err := tx.Query(ctx, `
|
||||
SELECT auth_key_id
|
||||
FROM auth_keys
|
||||
WHERE auth_key_id = ANY($1::bigint[])
|
||||
ORDER BY auth_key_id
|
||||
FOR UPDATE SKIP LOCKED`, candidates)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("lock orphan auth key candidates: %w", err)
|
||||
}
|
||||
lockedIDs := make([]int64, 0, len(candidates))
|
||||
for lockRows.Next() {
|
||||
var id int64
|
||||
if err := lockRows.Scan(&id); err != nil {
|
||||
lockRows.Close()
|
||||
return 0, fmt.Errorf("scan locked orphan auth key: %w", err)
|
||||
}
|
||||
lockedIDs = append(lockedIDs, id)
|
||||
}
|
||||
if err := lockRows.Err(); err != nil {
|
||||
lockRows.Close()
|
||||
return 0, fmt.Errorf("iterate locked orphan auth keys: %w", err)
|
||||
}
|
||||
lockRows.Close()
|
||||
if len(lockedIDs) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
// Phase 3 is a separate statement snapshot and repeats every ownership,
|
||||
// activity and protection predicate. Never delete from the phase-1 hint.
|
||||
var deleted int
|
||||
err = tx.QueryRow(ctx, `
|
||||
WITH still_orphaned AS MATERIALIZED (
|
||||
SELECT k.auth_key_id
|
||||
FROM auth_keys k
|
||||
WHERE k.last_used_at < now() - make_interval(secs => $1::double precision)
|
||||
AND NOT (k.auth_key_id = ANY($2::bigint[]))
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM authorizations a WHERE a.auth_key_id = k.auth_key_id
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM temp_auth_key_bindings b
|
||||
WHERE b.temp_auth_key_id = k.auth_key_id OR b.perm_auth_key_id = k.auth_key_id
|
||||
)
|
||||
ORDER BY k.last_used_at ASC, k.auth_key_id ASC
|
||||
LIMIT $3
|
||||
FOR UPDATE OF k SKIP LOCKED
|
||||
), deleted_update_states AS (
|
||||
-- Historical authorization-only deletion could leave a cursor without an
|
||||
-- auth_keys FK. GC owns that stale row once the raw key is proven orphaned.
|
||||
DELETE FROM update_states s
|
||||
USING candidates c
|
||||
WHERE s.auth_key_id = c.auth_key_id
|
||||
RETURNING s.auth_key_id
|
||||
), deleted_keys AS (
|
||||
DELETE FROM auth_keys k
|
||||
USING candidates c
|
||||
WHERE k.auth_key_id = c.auth_key_id
|
||||
FROM auth_keys AS k
|
||||
WHERE k.auth_key_id = ANY($3::bigint[])
|
||||
AND k.last_used_at < now() - make_interval(secs => $1::double precision)
|
||||
AND NOT (k.auth_key_id = ANY($2::bigint[]))
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM authorizations a WHERE a.auth_key_id = k.auth_key_id
|
||||
SELECT 1 FROM authorizations AS a WHERE a.auth_key_id = k.auth_key_id
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM temp_auth_key_bindings b
|
||||
FROM temp_auth_key_bindings AS b
|
||||
WHERE b.temp_auth_key_id = k.auth_key_id OR b.perm_auth_key_id = k.auth_key_id
|
||||
)
|
||||
RETURNING k.auth_key_id
|
||||
), deleted_update_states AS (
|
||||
DELETE FROM update_states AS state
|
||||
USING still_orphaned AS orphan
|
||||
WHERE state.auth_key_id = orphan.auth_key_id
|
||||
RETURNING state.auth_key_id
|
||||
), deleted_keys AS (
|
||||
DELETE FROM auth_keys AS key
|
||||
USING still_orphaned AS orphan
|
||||
WHERE key.auth_key_id = orphan.auth_key_id
|
||||
RETURNING key.auth_key_id
|
||||
)
|
||||
SELECT count(*)::int
|
||||
SELECT count(*)::integer
|
||||
FROM deleted_keys
|
||||
CROSS JOIN LATERAL (SELECT count(*) FROM deleted_update_states) AS touched`, olderThan.Seconds(), protectedIDs, limit).Scan(&deleted)
|
||||
CROSS JOIN LATERAL (SELECT count(*) FROM deleted_update_states) AS touched`,
|
||||
olderThan.Seconds(), protectedIDs, lockedIDs,
|
||||
).Scan(&deleted)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("delete orphaned auth keys: %w", err)
|
||||
return 0, fmt.Errorf("delete revalidated orphan auth keys: %w", err)
|
||||
}
|
||||
return deleted, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,9 @@ package postgres
|
|||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
|
@ -18,6 +20,13 @@ func testPool(t *testing.T) *pgxpool.Pool {
|
|||
if dsn == "" {
|
||||
t.Skip("set TELESRV_TEST_POSTGRES_DSN to run postgres integration test")
|
||||
}
|
||||
parsed, err := pgxpool.ParseConfig(dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("parse TELESRV_TEST_POSTGRES_DSN: %v", err)
|
||||
}
|
||||
if !strings.Contains(strings.ToLower(parsed.ConnConfig.Database), "test") {
|
||||
t.Fatalf("TELESRV_TEST_POSTGRES_DSN must name a dedicated test database, got %q", parsed.ConnConfig.Database)
|
||||
}
|
||||
if err := Migrate(dsn); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
|
|
@ -47,7 +56,12 @@ func TestAuthKeyStoreRoundTrip(t *testing.T) {
|
|||
_, _ = pool.Exec(ctx, "DELETE FROM auth_keys WHERE auth_key_id = $1", authKeyIDToInt64(id))
|
||||
})
|
||||
|
||||
want := store.AuthKeyData{ID: id, Value: val, ServerSalt: 0x0badf00d}
|
||||
want := store.AuthKeyData{
|
||||
ID: id,
|
||||
Value: val,
|
||||
ServerSalt: 0x0badf00d,
|
||||
ExpiresAt: 1_799_999_999,
|
||||
}
|
||||
if err := NewAuthKeyStore(pool).Save(ctx, want); err != nil {
|
||||
t.Fatalf("save: %v", err)
|
||||
}
|
||||
|
|
@ -59,9 +73,18 @@ func TestAuthKeyStoreRoundTrip(t *testing.T) {
|
|||
if !found {
|
||||
t.Fatal("auth key not found after save (重启后丢失)")
|
||||
}
|
||||
if got.ID != want.ID || got.Value != want.Value || got.ServerSalt != want.ServerSalt {
|
||||
t.Fatalf("round trip mismatch: got salt=%#x value[:4]=%x, want salt=%#x value[:4]=%x",
|
||||
got.ServerSalt, got.Value[:4], want.ServerSalt, want.Value[:4])
|
||||
if got.ID != want.ID || got.Value != want.Value || got.ServerSalt != want.ServerSalt || got.ExpiresAt != want.ExpiresAt {
|
||||
t.Fatalf("round trip mismatch: got salt=%#x expires_at=%d value[:4]=%x, want salt=%#x expires_at=%d value[:4]=%x",
|
||||
got.ServerSalt, got.ExpiresAt, got.Value[:4], want.ServerSalt, want.ExpiresAt, want.Value[:4])
|
||||
}
|
||||
conflicting := want
|
||||
conflicting.ExpiresAt++
|
||||
if err := NewAuthKeyStore(pool).Save(ctx, conflicting); !errors.Is(err, store.ErrAuthKeyProtocolMetadataConflict) {
|
||||
t.Fatalf("reclassify auth key error = %v, want %v", err, store.ErrAuthKeyProtocolMetadataConflict)
|
||||
}
|
||||
got, found, err = NewAuthKeyStore(pool).Get(ctx, id)
|
||||
if err != nil || !found || got.ExpiresAt != want.ExpiresAt {
|
||||
t.Fatalf("auth key expiry changed after rejected reclassification: got=%d found=%v err=%v", got.ExpiresAt, found, err)
|
||||
}
|
||||
|
||||
var missing [8]byte
|
||||
|
|
@ -129,3 +152,69 @@ func TestAuthKeyStoreClientInfoRoundTrip(t *testing.T) {
|
|||
t.Fatalf("partial client info merge mismatch: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthKeyStoreUpdateClientInfoProtectsObservedLayerPostgres(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
keys := NewAuthKeyStore(pool)
|
||||
|
||||
var id [8]byte
|
||||
var value [256]byte
|
||||
if _, err := rand.Read(id[:]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := rand.Read(value[:]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = keys.Delete(ctx, id) })
|
||||
if err := keys.Save(ctx, store.AuthKeyData{ID: id, Value: value}); err != nil {
|
||||
t.Fatalf("save auth key: %v", err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, `
|
||||
UPDATE auth_keys
|
||||
SET layer = 227, layer_observation_id = 91,
|
||||
device_model = 'before', platform = 'tdesktop'
|
||||
WHERE auth_key_id = $1`, authKeyIDToInt64(id)); err != nil {
|
||||
t.Fatalf("seed ordered layer: %v", err)
|
||||
}
|
||||
|
||||
err := keys.UpdateClientInfo(ctx, id, store.AuthKeyClientInfo{
|
||||
Layer: 220, DeviceModel: "must-not-merge", AppVersion: "must-not-merge",
|
||||
})
|
||||
if !errors.Is(err, store.ErrAuthKeySessionLayerConflict) {
|
||||
t.Fatalf("conflicting layer update error = %v, want %v", err, store.ErrAuthKeySessionLayerConflict)
|
||||
}
|
||||
got, found, err := keys.Get(ctx, id)
|
||||
if err != nil || !found {
|
||||
t.Fatalf("get after conflict: found=%v err=%v", found, err)
|
||||
}
|
||||
if got.Layer != 227 || got.LayerObservationID != 91 || got.DeviceModel != "before" ||
|
||||
got.Platform != "tdesktop" || got.AppVersion != "" {
|
||||
t.Fatalf("conflicting update changed row: %+v", got)
|
||||
}
|
||||
|
||||
if err := keys.UpdateClientInfo(ctx, id, store.AuthKeyClientInfo{
|
||||
Layer: 227, DeviceModel: "same-layer", AppVersion: "1.0",
|
||||
}); err != nil {
|
||||
t.Fatalf("same observed layer metadata merge: %v", err)
|
||||
}
|
||||
if err := keys.UpdateClientInfo(ctx, id, store.AuthKeyClientInfo{
|
||||
Platform: "windows", SystemVersion: "11",
|
||||
}); err != nil {
|
||||
t.Fatalf("layerless metadata merge: %v", err)
|
||||
}
|
||||
got, found, err = keys.Get(ctx, id)
|
||||
if err != nil || !found {
|
||||
t.Fatalf("get guarded metadata merge: found=%v err=%v", found, err)
|
||||
}
|
||||
if got.Layer != 227 || got.LayerObservationID != 91 || got.DeviceModel != "same-layer" ||
|
||||
got.Platform != "windows" || got.SystemVersion != "11" || got.AppVersion != "1.0" {
|
||||
t.Fatalf("guarded metadata merge = %+v", got)
|
||||
}
|
||||
|
||||
missing := id
|
||||
missing[0] ^= 0xff
|
||||
if err := keys.UpdateClientInfo(ctx, missing, store.AuthKeyClientInfo{Layer: 227}); !errors.Is(err, store.ErrAuthKeyNotFound) {
|
||||
t.Fatalf("missing primary update error = %v, want %v", err, store.ErrAuthKeyNotFound)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,22 +17,23 @@ func TestAuthKeyStoreDeleteOrphanedIsBoundedAndProtectsReferencesPostgres(t *tes
|
|||
auths := NewAuthorizationStore(pool)
|
||||
userID := createRevokeTestUser(t, ctx, pool, "orphan-auth-key")
|
||||
|
||||
newKey := func() [8]byte {
|
||||
newKey := func(expiresAt int) [8]byte {
|
||||
var id [8]byte
|
||||
if _, err := rand.Read(id[:]); err != nil {
|
||||
t.Fatalf("random auth key id: %v", err)
|
||||
}
|
||||
if err := keys.Save(ctx, store.AuthKeyData{ID: id}); err != nil {
|
||||
if err := keys.Save(ctx, store.AuthKeyData{ID: id, ExpiresAt: expiresAt}); err != nil {
|
||||
t.Fatalf("save auth key %x: %v", id, err)
|
||||
}
|
||||
t.Cleanup(func() { _ = keys.Delete(ctx, id) })
|
||||
return id
|
||||
}
|
||||
orphanOne, orphanTwo := newKey(), newKey()
|
||||
recent := newKey()
|
||||
authorized := newKey()
|
||||
temp, perm := newKey(), newKey()
|
||||
active := newKey()
|
||||
tempExpiry := int(time.Now().Add(time.Hour).Unix())
|
||||
orphanOne, orphanTwo := newKey(0), newKey(0)
|
||||
recent := newKey(0)
|
||||
authorized := newKey(0)
|
||||
temp, perm := newKey(tempExpiry), newKey(0)
|
||||
active := newKey(0)
|
||||
if _, err := pool.Exec(ctx, `
|
||||
INSERT INTO update_states (auth_key_id, user_id, pts, observed_pts)
|
||||
VALUES ($1, $3, 0, 0), ($2, $3, 0, 0)`,
|
||||
|
|
@ -45,7 +46,7 @@ VALUES ($1, $3, 0, 0), ($2, $3, 0, 0)`,
|
|||
}
|
||||
if err := NewTempAuthKeyBindingStore(pool).Save(ctx, domain.TempAuthKeyBinding{
|
||||
TempAuthKeyID: temp, PermAuthKeyID: authKeyIDToInt64(perm), Nonce: 1,
|
||||
TempSessionID: 2, ExpiresAt: int(time.Now().Add(time.Hour).Unix()), EncryptedMessage: []byte{1},
|
||||
TempSessionID: 2, ExpiresAt: tempExpiry, EncryptedMessage: []byte{1},
|
||||
}); err != nil {
|
||||
t.Fatalf("save temp binding: %v", err)
|
||||
}
|
||||
|
|
@ -107,8 +108,9 @@ func TestAuthKeyStoreDeleteCleansPermanentAndTempUpdateStatesPostgres(t *testing
|
|||
userID := createRevokeTestUser(t, ctx, pool, "auth-key-delete-state")
|
||||
perm := randomUpdateRetentionAuthKey(t)
|
||||
temp := randomUpdateRetentionAuthKey(t)
|
||||
for _, id := range [][8]byte{perm, temp} {
|
||||
if err := keys.Save(ctx, store.AuthKeyData{ID: id}); err != nil {
|
||||
tempExpiry := int(time.Now().Add(time.Hour).Unix())
|
||||
for id, expiresAt := range map[[8]byte]int{perm: 0, temp: tempExpiry} {
|
||||
if err := keys.Save(ctx, store.AuthKeyData{ID: id, ExpiresAt: expiresAt}); err != nil {
|
||||
t.Fatalf("save auth key %x: %v", id, err)
|
||||
}
|
||||
id := id
|
||||
|
|
@ -119,7 +121,7 @@ func TestAuthKeyStoreDeleteCleansPermanentAndTempUpdateStatesPostgres(t *testing
|
|||
PermAuthKeyID: authKeyIDToInt64(perm),
|
||||
Nonce: 31,
|
||||
TempSessionID: 32,
|
||||
ExpiresAt: int(time.Now().Add(time.Hour).Unix()),
|
||||
ExpiresAt: tempExpiry,
|
||||
EncryptedMessage: []byte{1},
|
||||
}); err != nil {
|
||||
t.Fatalf("save temp binding: %v", err)
|
||||
|
|
@ -147,6 +149,61 @@ SELECT
|
|||
}
|
||||
}
|
||||
|
||||
func TestTempAuthKeyRetentionUsesAuthKeyExpiryForBoundAndUnboundKeysPostgres(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
keys := NewAuthKeyStore(pool)
|
||||
bindings := NewTempAuthKeyBindingStore(pool)
|
||||
cutoff := int64(time.Now().Add(-time.Hour).Unix())
|
||||
unbound := randomUpdateRetentionAuthKey(t)
|
||||
bound := randomUpdateRetentionAuthKey(t)
|
||||
live := randomUpdateRetentionAuthKey(t)
|
||||
perm := randomUpdateRetentionAuthKey(t)
|
||||
expiries := map[[8]byte]int{
|
||||
unbound: int(cutoff - 2),
|
||||
bound: int(cutoff - 1),
|
||||
live: int(cutoff + 1),
|
||||
perm: 0,
|
||||
}
|
||||
for id, expiresAt := range expiries {
|
||||
if err := keys.Save(ctx, store.AuthKeyData{ID: id, ExpiresAt: expiresAt}); err != nil {
|
||||
t.Fatalf("save key %x: %v", id, err)
|
||||
}
|
||||
id := id
|
||||
t.Cleanup(func() { _ = keys.Delete(ctx, id) })
|
||||
}
|
||||
if err := bindings.Save(ctx, domain.TempAuthKeyBinding{
|
||||
TempAuthKeyID: bound, PermAuthKeyID: authKeyIDToInt64(perm), Nonce: 41,
|
||||
TempSessionID: 42, ExpiresAt: expiries[bound], EncryptedMessage: []byte{1},
|
||||
}); err != nil {
|
||||
t.Fatalf("save expired bound key: %v", err)
|
||||
}
|
||||
|
||||
deleted, err := bindings.DeleteExpired(ctx, cutoff, 1)
|
||||
if err != nil || deleted != 1 {
|
||||
t.Fatalf("first bounded expiry delete = %d/%v, want 1/nil", deleted, err)
|
||||
}
|
||||
if _, found, err := keys.Get(ctx, unbound); err != nil || found {
|
||||
t.Fatalf("earliest unbound temp found=%v err=%v, want deleted", found, err)
|
||||
}
|
||||
if _, found, err := keys.Get(ctx, bound); err != nil || !found {
|
||||
t.Fatalf("second expired bound temp found=%v err=%v, want retained after limit=1", found, err)
|
||||
}
|
||||
|
||||
deleted, err = bindings.DeleteExpired(ctx, cutoff, 10)
|
||||
if err != nil || deleted != 1 {
|
||||
t.Fatalf("second expiry delete = %d/%v, want 1/nil", deleted, err)
|
||||
}
|
||||
if _, found, err := bindings.GetByTemp(ctx, bound); err != nil || found {
|
||||
t.Fatalf("binding after temp key cascade found=%v err=%v, want absent", found, err)
|
||||
}
|
||||
for name, id := range map[string][8]byte{"live temp": live, "permanent": perm} {
|
||||
if _, found, err := keys.Get(ctx, id); err != nil || !found {
|
||||
t.Fatalf("%s found=%v err=%v, want retained", name, found, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthKeyGetTouchPreventsOrphanCollectionPostgres(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
|
|
|
|||
245
internal/store/postgres/authkey_session_layer.go
Normal file
245
internal/store/postgres/authkey_session_layer.go
Normal file
|
|
@ -0,0 +1,245 @@
|
|||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
const maxAuthKeySessionLayerDeleteBatch = 100000
|
||||
|
||||
func (s *AuthKeyStore) GetSessionLayer(
|
||||
ctx context.Context,
|
||||
rawAuthKeyID [8]byte,
|
||||
sessionID int64,
|
||||
) (store.AuthKeySessionLayer, bool, error) {
|
||||
var value store.AuthKeySessionLayer
|
||||
err := s.db.QueryRow(ctx, `
|
||||
SELECT evidence.layer,
|
||||
evidence.msg_id,
|
||||
evidence.observation_id,
|
||||
evidence.expires_at,
|
||||
defaults.layer = evidence.layer
|
||||
AND defaults.layer_observation_id = evidence.observation_id
|
||||
FROM auth_key_session_layers AS evidence
|
||||
LEFT JOIN temp_auth_key_bindings AS binding
|
||||
ON binding.temp_auth_key_id = evidence.raw_auth_key_id
|
||||
JOIN auth_keys AS defaults
|
||||
ON defaults.auth_key_id = COALESCE(binding.perm_auth_key_id, evidence.raw_auth_key_id)
|
||||
WHERE evidence.raw_auth_key_id = $1
|
||||
AND evidence.session_id = $2
|
||||
AND evidence.expires_at > now()
|
||||
`, authKeyIDToInt64(rawAuthKeyID), sessionID).Scan(
|
||||
&value.Layer,
|
||||
&value.MessageID,
|
||||
&value.ObservationID,
|
||||
&value.ExpiresAt,
|
||||
&value.SharedDefault,
|
||||
)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return store.AuthKeySessionLayer{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return store.AuthKeySessionLayer{}, false, fmt.Errorf("get auth key session layer: %w", err)
|
||||
}
|
||||
return value, true, nil
|
||||
}
|
||||
|
||||
// AdvanceSessionLayer enters the permanent identity advisory gate before any
|
||||
// row lock when rawAuthKeyID is permanent or already-bound temporary. An
|
||||
// initially-unbound temp key that becomes bound while the raw row is acquired
|
||||
// rolls the attempt back and retries in the new identity. The session watermark
|
||||
// and every currently bound shared default then commit in one transaction.
|
||||
func (s *AuthKeyStore) AdvanceSessionLayer(
|
||||
ctx context.Context,
|
||||
rawAuthKeyID [8]byte,
|
||||
sessionID int64,
|
||||
layer int,
|
||||
msgID int64,
|
||||
) (store.AuthKeySessionLayer, bool, error) {
|
||||
expiresAt, validMessageID := store.AuthKeySessionLayerExpiry(msgID)
|
||||
if layer <= 0 || !validMessageID {
|
||||
return store.AuthKeySessionLayer{}, false, store.ErrAuthKeySessionLayerInvalid
|
||||
}
|
||||
var (
|
||||
current store.AuthKeySessionLayer
|
||||
applied bool
|
||||
)
|
||||
err := withAuthIdentityTx(ctx, s.db, "advance auth key session layer", func(tx pgx.Tx) error {
|
||||
var err error
|
||||
current, applied, err = advanceSessionLayerTx(
|
||||
ctx, tx, authKeyIDToInt64(rawAuthKeyID), sessionID, layer, msgID, expiresAt,
|
||||
)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return current, false, err
|
||||
}
|
||||
return current, applied, nil
|
||||
}
|
||||
|
||||
func advanceSessionLayerTx(
|
||||
ctx context.Context,
|
||||
tx pgx.Tx,
|
||||
rawID int64,
|
||||
sessionID int64,
|
||||
layer int,
|
||||
msgID int64,
|
||||
expiresAt time.Time,
|
||||
) (store.AuthKeySessionLayer, bool, error) {
|
||||
_, permID, _, err := lockRawAuthKeyInIdentityOrder(ctx, tx, rawID)
|
||||
if err != nil {
|
||||
return store.AuthKeySessionLayer{}, false, err
|
||||
}
|
||||
var (
|
||||
current store.AuthKeySessionLayer
|
||||
now time.Time
|
||||
)
|
||||
err = tx.QueryRow(ctx, `
|
||||
SELECT layer, msg_id, observation_id, expires_at, now()
|
||||
FROM auth_key_session_layers
|
||||
WHERE raw_auth_key_id = $1 AND session_id = $2
|
||||
FOR UPDATE
|
||||
`, rawID, sessionID).Scan(
|
||||
¤t.Layer,
|
||||
¤t.MessageID,
|
||||
¤t.ObservationID,
|
||||
¤t.ExpiresAt,
|
||||
&now,
|
||||
)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
if err := tx.QueryRow(ctx, `SELECT now()`).Scan(&now); err != nil {
|
||||
return store.AuthKeySessionLayer{}, false, fmt.Errorf("read session layer database time: %w", err)
|
||||
}
|
||||
current = store.AuthKeySessionLayer{}
|
||||
} else if err != nil {
|
||||
return store.AuthKeySessionLayer{}, false, fmt.Errorf("lock auth key session layer: %w", err)
|
||||
}
|
||||
if _, fresh := store.AuthKeySessionLayerEvidenceFresh(now, msgID); !fresh {
|
||||
return store.AuthKeySessionLayer{}, false, store.ErrAuthKeySessionLayerInvalid
|
||||
}
|
||||
if current.MessageID != 0 && now.Before(current.ExpiresAt) {
|
||||
switch {
|
||||
case msgID < current.MessageID:
|
||||
if err := tx.QueryRow(ctx, `
|
||||
SELECT layer = $2 AND layer_observation_id = $3
|
||||
FROM auth_keys WHERE auth_key_id = $1
|
||||
`, permID, current.Layer, current.ObservationID).Scan(¤t.SharedDefault); err != nil {
|
||||
return store.AuthKeySessionLayer{}, false, fmt.Errorf("compare older session layer with shared default: %w", err)
|
||||
}
|
||||
return current, false, nil
|
||||
case msgID == current.MessageID:
|
||||
if layer != current.Layer {
|
||||
return current, false, store.ErrAuthKeySessionLayerConflict
|
||||
}
|
||||
if err := tx.QueryRow(ctx, `
|
||||
SELECT layer = $2 AND layer_observation_id = $3
|
||||
FROM auth_keys WHERE auth_key_id = $1
|
||||
`, permID, current.Layer, current.ObservationID).Scan(¤t.SharedDefault); err != nil {
|
||||
return store.AuthKeySessionLayer{}, false, fmt.Errorf("compare duplicate session layer with shared default: %w", err)
|
||||
}
|
||||
return current, false, nil
|
||||
}
|
||||
}
|
||||
|
||||
var observationID int64
|
||||
if err := tx.QueryRow(ctx, `SELECT nextval('auth_key_layer_observation_seq')`).Scan(&observationID); err != nil {
|
||||
return store.AuthKeySessionLayer{}, false, fmt.Errorf("allocate auth key layer observation: %w", err)
|
||||
}
|
||||
err = tx.QueryRow(ctx, `
|
||||
INSERT INTO auth_key_session_layers (
|
||||
raw_auth_key_id, session_id, layer, msg_id, observation_id, expires_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6)
|
||||
ON CONFLICT (raw_auth_key_id, session_id) DO UPDATE SET
|
||||
layer = EXCLUDED.layer,
|
||||
msg_id = EXCLUDED.msg_id,
|
||||
observation_id = EXCLUDED.observation_id,
|
||||
expires_at = EXCLUDED.expires_at
|
||||
RETURNING layer, msg_id, observation_id, expires_at
|
||||
`, rawID, sessionID, layer, msgID, observationID, expiresAt).Scan(
|
||||
¤t.Layer,
|
||||
¤t.MessageID,
|
||||
¤t.ObservationID,
|
||||
¤t.ExpiresAt,
|
||||
)
|
||||
if err != nil {
|
||||
return store.AuthKeySessionLayer{}, false, fmt.Errorf("upsert auth key session layer: %w", err)
|
||||
}
|
||||
keyIDs := []int64{rawID}
|
||||
if permID != rawID {
|
||||
keyIDs = append(keyIDs, permID)
|
||||
}
|
||||
tag, err := tx.Exec(ctx, `
|
||||
UPDATE auth_keys
|
||||
SET layer = $2, layer_observation_id = $3
|
||||
WHERE auth_key_id = ANY($1::bigint[])
|
||||
AND layer_observation_id < $3
|
||||
`, keyIDs, layer, observationID)
|
||||
if err != nil {
|
||||
return store.AuthKeySessionLayer{}, false, fmt.Errorf("publish auth key session layer defaults: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() != int64(len(keyIDs)) {
|
||||
return store.AuthKeySessionLayer{}, false, fmt.Errorf("publish auth key session layer defaults: updated %d of %d locked keys", tag.RowsAffected(), len(keyIDs))
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE authorizations
|
||||
SET layer = $2
|
||||
WHERE auth_key_id = ANY($1::bigint[])
|
||||
`, keyIDs, layer); err != nil {
|
||||
return store.AuthKeySessionLayer{}, false, fmt.Errorf("mirror auth key session layer defaults: %w", err)
|
||||
}
|
||||
current.SharedDefault = true
|
||||
return current, true, nil
|
||||
}
|
||||
|
||||
func (s *AuthKeyStore) DeleteSessionLayer(
|
||||
ctx context.Context,
|
||||
rawAuthKeyID [8]byte,
|
||||
sessionID int64,
|
||||
) (bool, error) {
|
||||
tag, err := s.db.Exec(ctx, `
|
||||
DELETE FROM auth_key_session_layers
|
||||
WHERE raw_auth_key_id = $1 AND session_id = $2
|
||||
`, authKeyIDToInt64(rawAuthKeyID), sessionID)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("delete auth key session layer: %w", err)
|
||||
}
|
||||
return tag.RowsAffected() > 0, nil
|
||||
}
|
||||
|
||||
func (s *AuthKeyStore) DeleteExpiredSessionLayers(ctx context.Context, limit int) (int, error) {
|
||||
if limit <= 0 {
|
||||
return 0, nil
|
||||
}
|
||||
if limit > maxAuthKeySessionLayerDeleteBatch {
|
||||
limit = maxAuthKeySessionLayerDeleteBatch
|
||||
}
|
||||
var deleted int
|
||||
err := s.db.QueryRow(ctx, `
|
||||
WITH candidates AS MATERIALIZED (
|
||||
SELECT raw_auth_key_id, session_id
|
||||
FROM auth_key_session_layers
|
||||
WHERE expires_at <= now()
|
||||
ORDER BY expires_at, raw_auth_key_id, session_id
|
||||
LIMIT $1
|
||||
FOR UPDATE SKIP LOCKED
|
||||
), removed AS (
|
||||
DELETE FROM auth_key_session_layers AS evidence
|
||||
USING candidates
|
||||
WHERE evidence.raw_auth_key_id = candidates.raw_auth_key_id
|
||||
AND evidence.session_id = candidates.session_id
|
||||
AND evidence.expires_at <= now()
|
||||
RETURNING 1
|
||||
)
|
||||
SELECT count(*)::integer FROM removed
|
||||
`, limit).Scan(&deleted)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("delete expired auth key session layers: %w", err)
|
||||
}
|
||||
return deleted, nil
|
||||
}
|
||||
|
|
@ -0,0 +1,140 @@
|
|||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
func TestAuthKeySessionLayerTransactionAndRestartPostgres(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
temp := randomLayerTestAuthKeyID(t)
|
||||
perm := randomLayerTestAuthKeyID(t)
|
||||
for perm == temp {
|
||||
perm = randomLayerTestAuthKeyID(t)
|
||||
}
|
||||
const sessionID = int64(87001)
|
||||
t.Cleanup(func() {
|
||||
_ = NewAuthKeyStore(pool).Delete(ctx, perm)
|
||||
_ = NewAuthKeyStore(pool).Delete(ctx, temp)
|
||||
})
|
||||
|
||||
keys := NewAuthKeyStore(pool)
|
||||
expiresAt := int(time.Now().Add(time.Hour).Unix())
|
||||
if err := keys.Save(ctx, store.AuthKeyData{ID: temp, ExpiresAt: expiresAt}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := keys.Save(ctx, store.AuthKeyData{ID: perm, ExpiresAt: 0}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
firstMsgID := authKeySessionLayerTestMsgID(now, 1)
|
||||
newerMsgID := authKeySessionLayerTestMsgID(now, 2)
|
||||
concurrentLowMsgID := authKeySessionLayerTestMsgID(now, 3)
|
||||
concurrentHighMsgID := authKeySessionLayerTestMsgID(now, 4)
|
||||
for _, invalidMsgID := range []int64{
|
||||
authKeySessionLayerTestMsgID(now.Add(-302*time.Second), 1),
|
||||
authKeySessionLayerTestMsgID(now.Add(31*time.Second), 1),
|
||||
firstMsgID + 1,
|
||||
} {
|
||||
if _, _, err := keys.AdvanceSessionLayer(ctx, temp, sessionID, 220, invalidMsgID); !errors.Is(err, store.ErrAuthKeySessionLayerInvalid) {
|
||||
t.Fatalf("invalid msg_id %d advance err = %v", invalidMsgID, err)
|
||||
}
|
||||
}
|
||||
if _, found, err := keys.GetSessionLayer(ctx, temp, sessionID); err != nil || found {
|
||||
t.Fatalf("rejected evidence created session row: found=%v err=%v", found, err)
|
||||
}
|
||||
first, applied, err := keys.AdvanceSessionLayer(ctx, temp, sessionID, 220, firstMsgID)
|
||||
if err != nil || !applied || !first.SharedDefault || first.ObservationID <= 0 {
|
||||
t.Fatalf("first advance = (%+v,%v,%v)", first, applied, err)
|
||||
}
|
||||
if err := NewTempAuthKeyBindingStore(pool).Save(ctx, domain.TempAuthKeyBinding{
|
||||
TempAuthKeyID: temp,
|
||||
PermAuthKeyID: int64(binary.LittleEndian.Uint64(perm[:])),
|
||||
Nonce: 87,
|
||||
TempSessionID: sessionID,
|
||||
ExpiresAt: expiresAt,
|
||||
EncryptedMessage: []byte{8, 7},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, id := range [][8]byte{temp, perm} {
|
||||
got, found, err := NewAuthKeyStore(pool).Get(ctx, id)
|
||||
if err != nil || !found || got.Layer != 220 || got.LayerObservationID != first.ObservationID {
|
||||
t.Fatalf("bound default %x = (%+v,%v,%v)", id, got, found, err)
|
||||
}
|
||||
}
|
||||
|
||||
newer, applied, err := NewAuthKeyStore(pool).AdvanceSessionLayer(ctx, temp, sessionID, 227, newerMsgID)
|
||||
if err != nil || !applied || !newer.SharedDefault || newer.ObservationID <= first.ObservationID {
|
||||
t.Fatalf("newer advance = (%+v,%v,%v)", newer, applied, err)
|
||||
}
|
||||
old, applied, err := NewAuthKeyStore(pool).AdvanceSessionLayer(ctx, temp, sessionID, 220, firstMsgID)
|
||||
if err != nil || applied || old.Layer != 227 || old.MessageID != newerMsgID || !old.SharedDefault {
|
||||
t.Fatalf("old replay = (%+v,%v,%v)", old, applied, err)
|
||||
}
|
||||
if _, _, err := NewAuthKeyStore(pool).AdvanceSessionLayer(ctx, temp, sessionID, 220, newerMsgID); !errors.Is(err, store.ErrAuthKeySessionLayerConflict) {
|
||||
t.Fatalf("same-msg conflict = %v", err)
|
||||
}
|
||||
|
||||
// Two independent store instances model two server processes. The raw-key
|
||||
// row lock and session CAS must converge on the greater selector msg_id.
|
||||
type candidate struct {
|
||||
layer int
|
||||
msgID int64
|
||||
}
|
||||
candidates := []candidate{{layer: 225, msgID: concurrentLowMsgID}, {layer: 227, msgID: concurrentHighMsgID}}
|
||||
start := make(chan struct{})
|
||||
errs := make(chan error, len(candidates))
|
||||
var wg sync.WaitGroup
|
||||
for _, item := range candidates {
|
||||
item := item
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
_, _, err := NewAuthKeyStore(pool).AdvanceSessionLayer(ctx, temp, sessionID, item.layer, item.msgID)
|
||||
errs <- err
|
||||
}()
|
||||
}
|
||||
close(start)
|
||||
wg.Wait()
|
||||
close(errs)
|
||||
for err := range errs {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
restarted := NewAuthKeyStore(pool)
|
||||
current, found, err := restarted.GetSessionLayer(ctx, temp, sessionID)
|
||||
if err != nil || !found || current.Layer != 227 || current.MessageID != concurrentHighMsgID || !current.SharedDefault {
|
||||
t.Fatalf("restart authoritative row = (%+v,%v,%v)", current, found, err)
|
||||
}
|
||||
for _, id := range [][8]byte{temp, perm} {
|
||||
got, found, err := restarted.Get(ctx, id)
|
||||
if err != nil || !found || got.Layer != 227 || got.LayerObservationID != current.ObservationID {
|
||||
t.Fatalf("transactional shared default %x = (%+v,%v,%v)", id, got, found, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func authKeySessionLayerTestMsgID(at time.Time, order uint32) int64 {
|
||||
return int64((uint64(at.Unix()) << 32) | uint64(order)<<2)
|
||||
}
|
||||
|
||||
func randomLayerTestAuthKeyID(t *testing.T) [8]byte {
|
||||
t.Helper()
|
||||
var id [8]byte
|
||||
if _, err := rand.Read(id[:]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
|
@ -10,6 +10,7 @@ import (
|
|||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
"telesrv/internal/store/postgres/sqlcgen"
|
||||
)
|
||||
|
||||
|
|
@ -28,17 +29,9 @@ func (s *AuthorizationStore) Bind(ctx context.Context, a domain.Authorization) e
|
|||
if a.Hash == 0 {
|
||||
a.Hash = authorizationHash(a.AuthKeyID)
|
||||
}
|
||||
bind := func(db sqlcgen.DBTX) error {
|
||||
return bindAuthorization(ctx, db, a)
|
||||
}
|
||||
var err error
|
||||
if tx, ok := s.db.(pgx.Tx); ok {
|
||||
err = bind(tx)
|
||||
} else {
|
||||
err = withTx(ctx, s.db, "bind authorization", func(tx pgx.Tx) error {
|
||||
return bind(tx)
|
||||
})
|
||||
}
|
||||
err := withAuthIdentityTx(ctx, s.db, "bind authorization", func(tx pgx.Tx) error {
|
||||
return bindAuthorization(ctx, tx, a)
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("upsert authorization: %w", err)
|
||||
}
|
||||
|
|
@ -54,14 +47,35 @@ func (s *AuthorizationStore) Bind(ctx context.Context, a domain.Authorization) e
|
|||
// raw auth key 的并发登录/换号。
|
||||
func bindAuthorization(ctx context.Context, db sqlcgen.DBTX, a domain.Authorization) error {
|
||||
keyID := authKeyIDToInt64(a.AuthKeyID)
|
||||
var lockedKeyID int64
|
||||
tx, ok := db.(pgx.Tx)
|
||||
if !ok {
|
||||
return fmt.Errorf("bind authorization requires a transaction")
|
||||
}
|
||||
if err := lockPermanentAuthIdentities(ctx, tx, []int64{keyID}); err != nil {
|
||||
return err
|
||||
}
|
||||
var (
|
||||
lockedKeyID int64
|
||||
expiresAt int
|
||||
authLayer int
|
||||
layerObservationID int64
|
||||
)
|
||||
if err := db.QueryRow(ctx, `
|
||||
SELECT auth_key_id
|
||||
SELECT auth_key_id, expires_at, layer, layer_observation_id
|
||||
FROM auth_keys
|
||||
WHERE auth_key_id = $1
|
||||
FOR UPDATE`, keyID).Scan(&lockedKeyID); err != nil {
|
||||
FOR UPDATE`, keyID).Scan(&lockedKeyID, &expiresAt, &authLayer, &layerObservationID); err != nil {
|
||||
return fmt.Errorf("lock auth key for authorization: %w", err)
|
||||
}
|
||||
if expiresAt != 0 {
|
||||
return store.ErrAuthKeyNotPermanent
|
||||
}
|
||||
if authLayer < 0 || layerObservationID < 0 || (layerObservationID > 0 && authLayer == 0) {
|
||||
return fmt.Errorf(
|
||||
"authorization auth-key layer invariant violation: auth key %x has layer %d observation %d",
|
||||
a.AuthKeyID, authLayer, layerObservationID,
|
||||
)
|
||||
}
|
||||
|
||||
if _, err := db.Exec(ctx, `
|
||||
INSERT INTO user_update_watermarks (user_id, contiguous_pts)
|
||||
|
|
@ -147,7 +161,7 @@ ON CONFLICT (auth_key_id) DO UPDATE SET
|
|||
ip = EXCLUDED.ip,
|
||||
password_pending = EXCLUDED.password_pending,
|
||||
active_at = now()`,
|
||||
keyID, a.UserID, a.Hash, int32(a.Layer), a.DeviceModel, a.Platform, a.SystemVersion, int32(a.APIID), a.AppVersion, a.IP, a.PasswordPending,
|
||||
keyID, a.UserID, a.Hash, int32(authLayer), a.DeviceModel, a.Platform, a.SystemVersion, int32(a.APIID), a.AppVersion, a.IP, a.PasswordPending,
|
||||
); err != nil {
|
||||
return fmt.Errorf("write authorization: %w", err)
|
||||
}
|
||||
|
|
@ -171,18 +185,6 @@ FROM authorizations WHERE auth_key_id = $1`, authKeyIDToInt64(id))
|
|||
return a, true, nil
|
||||
}
|
||||
|
||||
func (s *AuthorizationStore) UpdateLayer(ctx context.Context, id [8]byte, layer int) error {
|
||||
if layer <= 0 {
|
||||
return nil
|
||||
}
|
||||
if _, err := s.db.Exec(ctx, `
|
||||
UPDATE authorizations SET layer = $2, active_at = now() WHERE auth_key_id = $1`,
|
||||
authKeyIDToInt64(id), int32(layer)); err != nil {
|
||||
return fmt.Errorf("update authorization layer: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *AuthorizationStore) UpdateClientInfo(ctx context.Context, id [8]byte, info domain.AuthKeyClientInfo) error {
|
||||
if _, err := s.db.Exec(ctx, `
|
||||
UPDATE authorizations SET
|
||||
|
|
@ -254,45 +256,71 @@ RETURNING auth_key_id, user_id, hash, layer, device_model, platform, system_vers
|
|||
// authorizations 通过 FK cascade 删除;update_states 没有 auth_keys FK,必须显式清理;
|
||||
// 关联 temp auth key 也显式删除,避免 raw temp key 重连。
|
||||
func (s *AuthorizationStore) RevokeByHash(ctx context.Context, userID, hash int64) (domain.Authorization, bool, error) {
|
||||
row := s.db.QueryRow(ctx, `
|
||||
WITH target AS MATERIALIZED (
|
||||
SELECT auth_key_id, user_id, hash, layer, device_model, platform, system_version, api_id, app_version, ip, password_pending, created_at, active_at
|
||||
FROM authorizations
|
||||
WHERE user_id = $1 AND hash = $2
|
||||
), deleted_temp AS (
|
||||
DELETE FROM auth_keys
|
||||
WHERE auth_key_id IN (
|
||||
SELECT temp_auth_key_id
|
||||
FROM temp_auth_key_bindings
|
||||
WHERE perm_auth_key_id IN (SELECT auth_key_id FROM target)
|
||||
var (
|
||||
a domain.Authorization
|
||||
found bool
|
||||
)
|
||||
RETURNING auth_key_id
|
||||
), deleted_update_states AS (
|
||||
DELETE FROM update_states
|
||||
WHERE auth_key_id IN (SELECT auth_key_id FROM target)
|
||||
RETURNING auth_key_id
|
||||
), deleted_keys AS (
|
||||
DELETE FROM auth_keys
|
||||
WHERE auth_key_id IN (SELECT auth_key_id FROM target)
|
||||
RETURNING auth_key_id
|
||||
), touched AS (
|
||||
SELECT
|
||||
(SELECT count(*) FROM deleted_temp) +
|
||||
(SELECT count(*) FROM deleted_update_states) AS count
|
||||
)
|
||||
SELECT target.auth_key_id, target.user_id, target.hash, target.layer, target.device_model, target.platform,
|
||||
target.system_version, target.api_id, target.app_version, target.ip, target.password_pending,
|
||||
target.created_at, target.active_at
|
||||
FROM target
|
||||
JOIN deleted_keys USING (auth_key_id)
|
||||
CROSS JOIN touched`, userID, hash)
|
||||
a, found, err := scanRevokedAuthorization(row)
|
||||
err := withAuthIdentityTx(ctx, s.db, "revoke authorization by hash", func(tx pgx.Tx) error {
|
||||
var err error
|
||||
a, found, err = revokeByHashTx(ctx, tx, userID, hash)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return domain.Authorization{}, false, fmt.Errorf("revoke authorization by hash: %w", err)
|
||||
return domain.Authorization{}, false, err
|
||||
}
|
||||
return a, found, nil
|
||||
}
|
||||
|
||||
// revokeByHashTx deliberately uses separate READ COMMITTED statements. The first
|
||||
// lookup is only a candidate. Bind locks auth_keys before changing authorization
|
||||
// ownership, so revocation must lock the same parent row and then re-read the
|
||||
// owner/hash from a fresh statement snapshot. Otherwise an A->B re-login that
|
||||
// commits while revoke waits can be deleted using A's stale target snapshot.
|
||||
func revokeByHashTx(ctx context.Context, tx pgx.Tx, userID, hash int64) (domain.Authorization, bool, error) {
|
||||
var candidate int64
|
||||
if err := tx.QueryRow(ctx, `
|
||||
SELECT auth_key_id
|
||||
FROM authorizations
|
||||
WHERE user_id = $1 AND hash = $2`, userID, hash).Scan(&candidate); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.Authorization{}, false, nil
|
||||
}
|
||||
return domain.Authorization{}, false, fmt.Errorf("select revoke candidate by hash: %w", err)
|
||||
}
|
||||
if err := lockPermanentAuthIdentities(ctx, tx, []int64{candidate}); err != nil {
|
||||
return domain.Authorization{}, false, err
|
||||
}
|
||||
|
||||
var locked int64
|
||||
if err := tx.QueryRow(ctx, `
|
||||
SELECT auth_key_id
|
||||
FROM auth_keys
|
||||
WHERE auth_key_id = $1
|
||||
FOR UPDATE`, candidate).Scan(&locked); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.Authorization{}, false, nil
|
||||
}
|
||||
return domain.Authorization{}, false, fmt.Errorf("lock revoke auth key by hash: %w", err)
|
||||
}
|
||||
|
||||
a, found, err := scanRevokedAuthorization(tx.QueryRow(ctx, `
|
||||
SELECT auth_key_id, user_id, hash, layer, device_model, platform, system_version,
|
||||
api_id, app_version, ip, password_pending, created_at, active_at
|
||||
FROM authorizations
|
||||
WHERE auth_key_id = $1 AND user_id = $2 AND hash = $3
|
||||
FOR UPDATE`, candidate, userID, hash))
|
||||
if err != nil {
|
||||
return domain.Authorization{}, false, fmt.Errorf("revalidate revoke authorization by hash: %w", err)
|
||||
}
|
||||
if !found {
|
||||
return domain.Authorization{}, false, nil
|
||||
}
|
||||
if err := deleteRevocationTargetsTx(ctx, tx, []int64{candidate}); err != nil {
|
||||
return domain.Authorization{}, false, err
|
||||
}
|
||||
return a, true, nil
|
||||
}
|
||||
|
||||
func (s *AuthorizationStore) DeleteByUserExcept(ctx context.Context, userID int64, keepAuthKeyID [8]byte) ([]domain.Authorization, error) {
|
||||
rows, err := s.db.Query(ctx, `
|
||||
DELETE FROM authorizations
|
||||
|
|
@ -323,57 +351,140 @@ RETURNING auth_key_id, user_id, hash, layer, device_model, platform, system_vers
|
|||
|
||||
// RevokeByUserExcept 批量删除协议 auth_key,保留 keepAuthKeyID 对应的当前设备。
|
||||
func (s *AuthorizationStore) RevokeByUserExcept(ctx context.Context, userID int64, keepAuthKeyID [8]byte) ([]domain.Authorization, error) {
|
||||
rows, err := s.db.Query(ctx, `
|
||||
WITH target AS MATERIALIZED (
|
||||
SELECT auth_key_id, user_id, hash, layer, device_model, platform, system_version, api_id, app_version, ip, password_pending, created_at, active_at
|
||||
FROM authorizations
|
||||
WHERE user_id = $1 AND auth_key_id <> $2
|
||||
), deleted_temp AS (
|
||||
DELETE FROM auth_keys
|
||||
WHERE auth_key_id IN (
|
||||
SELECT temp_auth_key_id
|
||||
FROM temp_auth_key_bindings
|
||||
WHERE perm_auth_key_id IN (SELECT auth_key_id FROM target)
|
||||
)
|
||||
RETURNING auth_key_id
|
||||
), deleted_update_states AS (
|
||||
DELETE FROM update_states
|
||||
WHERE auth_key_id IN (SELECT auth_key_id FROM target)
|
||||
RETURNING auth_key_id
|
||||
), deleted_keys AS (
|
||||
DELETE FROM auth_keys
|
||||
WHERE auth_key_id IN (SELECT auth_key_id FROM target)
|
||||
RETURNING auth_key_id
|
||||
), touched AS (
|
||||
SELECT
|
||||
(SELECT count(*) FROM deleted_temp) +
|
||||
(SELECT count(*) FROM deleted_update_states) AS count
|
||||
)
|
||||
SELECT target.auth_key_id, target.user_id, target.hash, target.layer, target.device_model, target.platform,
|
||||
target.system_version, target.api_id, target.app_version, target.ip, target.password_pending,
|
||||
target.created_at, target.active_at
|
||||
FROM target
|
||||
JOIN deleted_keys USING (auth_key_id)
|
||||
CROSS JOIN touched
|
||||
ORDER BY target.created_at, target.auth_key_id`, userID, authKeyIDToInt64(keepAuthKeyID))
|
||||
var out []domain.Authorization
|
||||
err := withAuthIdentityTx(ctx, s.db, "revoke authorizations by user", func(tx pgx.Tx) error {
|
||||
var err error
|
||||
out, err = revokeByUserExceptTx(ctx, tx, userID, authKeyIDToInt64(keepAuthKeyID))
|
||||
return err
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
|
||||
func revokeByUserExceptTx(ctx context.Context, tx pgx.Tx, userID, keepAuthKeyID int64) ([]domain.Authorization, error) {
|
||||
candidateRows, err := tx.Query(ctx, `
|
||||
SELECT auth_key_id
|
||||
FROM authorizations
|
||||
WHERE user_id = $1 AND auth_key_id <> $2
|
||||
ORDER BY auth_key_id`, userID, keepAuthKeyID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("revoke authorizations by user: %w", err)
|
||||
return nil, fmt.Errorf("select revoke candidates by user: %w", err)
|
||||
}
|
||||
candidates := make([]int64, 0)
|
||||
for candidateRows.Next() {
|
||||
var id int64
|
||||
if err := candidateRows.Scan(&id); err != nil {
|
||||
candidateRows.Close()
|
||||
return nil, fmt.Errorf("scan revoke candidate by user: %w", err)
|
||||
}
|
||||
candidates = append(candidates, id)
|
||||
}
|
||||
if err := candidateRows.Err(); err != nil {
|
||||
candidateRows.Close()
|
||||
return nil, fmt.Errorf("iterate revoke candidates by user: %w", err)
|
||||
}
|
||||
candidateRows.Close()
|
||||
if len(candidates) == 0 {
|
||||
return []domain.Authorization{}, nil
|
||||
}
|
||||
if err := lockPermanentAuthIdentities(ctx, tx, candidates); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Advisory keys are already all held in final int32-hash order. Parent rows
|
||||
// are then locked by their real bigint IDs for deterministic batch behavior.
|
||||
lockRows, err := tx.Query(ctx, `
|
||||
SELECT auth_key_id
|
||||
FROM auth_keys
|
||||
WHERE auth_key_id = ANY($1::bigint[])
|
||||
ORDER BY auth_key_id
|
||||
FOR UPDATE`, candidates)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("lock revoke auth keys by user: %w", err)
|
||||
}
|
||||
for lockRows.Next() {
|
||||
var ignored int64
|
||||
if err := lockRows.Scan(&ignored); err != nil {
|
||||
lockRows.Close()
|
||||
return nil, fmt.Errorf("scan locked revoke auth key: %w", err)
|
||||
}
|
||||
}
|
||||
if err := lockRows.Err(); err != nil {
|
||||
lockRows.Close()
|
||||
return nil, fmt.Errorf("iterate locked revoke auth keys: %w", err)
|
||||
}
|
||||
lockRows.Close()
|
||||
|
||||
// This is intentionally a new statement snapshot after all parent locks.
|
||||
// Keys that changed owner while waiting are omitted and must remain intact.
|
||||
rows, err := tx.Query(ctx, `
|
||||
SELECT auth_key_id, user_id, hash, layer, device_model, platform, system_version,
|
||||
api_id, app_version, ip, password_pending, created_at, active_at
|
||||
FROM authorizations
|
||||
WHERE user_id = $1
|
||||
AND auth_key_id <> $2
|
||||
AND auth_key_id = ANY($3::bigint[])
|
||||
ORDER BY created_at, auth_key_id
|
||||
FOR UPDATE`, userID, keepAuthKeyID, candidates)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("revalidate revoke authorizations by user: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]domain.Authorization, 0)
|
||||
for rows.Next() {
|
||||
a, err := scanRevokedAuthorizationRow(rows)
|
||||
if err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, a)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
rows.Close()
|
||||
return nil, fmt.Errorf("iterate revoked authorizations: %w", err)
|
||||
}
|
||||
rows.Close()
|
||||
if len(out) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
targets := make([]int64, len(out))
|
||||
for i := range out {
|
||||
targets[i] = authKeyIDToInt64(out[i].AuthKeyID)
|
||||
}
|
||||
if err := deleteRevocationTargetsTx(ctx, tx, targets); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func deleteRevocationTargetsTx(ctx context.Context, tx pgx.Tx, authKeyIDs []int64) error {
|
||||
if len(authKeyIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
DELETE FROM auth_keys
|
||||
WHERE auth_key_id IN (
|
||||
SELECT temp_auth_key_id
|
||||
FROM temp_auth_key_bindings
|
||||
WHERE perm_auth_key_id = ANY($1::bigint[])
|
||||
)`, authKeyIDs); err != nil {
|
||||
return fmt.Errorf("delete revoked temporary auth keys: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
DELETE FROM update_states
|
||||
WHERE auth_key_id = ANY($1::bigint[])`, authKeyIDs); err != nil {
|
||||
return fmt.Errorf("delete revoked update states: %w", err)
|
||||
}
|
||||
tag, err := tx.Exec(ctx, `
|
||||
DELETE FROM auth_keys
|
||||
WHERE auth_key_id = ANY($1::bigint[])`, authKeyIDs)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete revoked permanent auth keys: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() != int64(len(authKeyIDs)) {
|
||||
return fmt.Errorf("delete revoked permanent auth keys: deleted %d of %d locked targets", tag.RowsAffected(), len(authKeyIDs))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func authorizationFromRow(row sqlcgen.Authorization) domain.Authorization {
|
||||
return domain.Authorization{
|
||||
AuthKeyID: authKeyIDFromInt64(row.AuthKeyID),
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package postgres
|
|||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
|
@ -20,9 +21,10 @@ func TestAuthorizationStoreRevokeByHashDeletesProtocolKeyCascadePostgres(t *test
|
|||
temp := revokeTestAuthKeyID(0x92)
|
||||
keys := NewAuthKeyStore(pool)
|
||||
auths := NewAuthorizationStore(pool)
|
||||
tempExpiry := int(time.Now().Add(time.Hour).Unix())
|
||||
|
||||
saveRevokeTestAuthKey(t, ctx, keys, perm)
|
||||
saveRevokeTestAuthKey(t, ctx, keys, temp)
|
||||
saveRevokeTestAuthKey(t, ctx, keys, perm, 0)
|
||||
saveRevokeTestAuthKey(t, ctx, keys, temp, tempExpiry)
|
||||
if err := auths.Bind(ctx, domain.Authorization{
|
||||
AuthKeyID: perm,
|
||||
UserID: userID,
|
||||
|
|
@ -46,7 +48,7 @@ func TestAuthorizationStoreRevokeByHashDeletesProtocolKeyCascadePostgres(t *test
|
|||
PermAuthKeyID: authKeyIDToInt64(perm),
|
||||
Nonce: 1,
|
||||
TempSessionID: 2,
|
||||
ExpiresAt: int(time.Now().Add(time.Hour).Unix()),
|
||||
ExpiresAt: tempExpiry,
|
||||
EncryptedMessage: []byte{1, 2, 3, 4},
|
||||
}); err != nil {
|
||||
t.Fatalf("save temp binding: %v", err)
|
||||
|
|
@ -77,20 +79,20 @@ func TestAuthorizationStoreRevokeByUserExceptDeletesOnlyRevokedKeysPostgres(t *t
|
|||
revokedTwo := revokeTestAuthKeyID(0xa3)
|
||||
tempForTwo := revokeTestAuthKeyID(0xa4)
|
||||
|
||||
for i, key := range [][8]byte{keep, revokedOne, revokedTwo, tempForTwo} {
|
||||
saveRevokeTestAuthKey(t, ctx, keys, key)
|
||||
if i < 3 {
|
||||
if err := auths.Bind(ctx, domain.Authorization{AuthKeyID: key, UserID: userID, Hash: int64(9100 + i)}); err != nil {
|
||||
t.Fatalf("bind auth %x: %v", key, err)
|
||||
}
|
||||
for i, key := range [][8]byte{keep, revokedOne, revokedTwo} {
|
||||
saveRevokeTestAuthKey(t, ctx, keys, key, 0)
|
||||
if err := auths.Bind(ctx, domain.Authorization{AuthKeyID: key, UserID: userID, Hash: int64(9100 + i)}); err != nil {
|
||||
t.Fatalf("bind auth %x: %v", key, err)
|
||||
}
|
||||
}
|
||||
tempExpiry := int(time.Now().Add(time.Hour).Unix())
|
||||
saveRevokeTestAuthKey(t, ctx, keys, tempForTwo, tempExpiry)
|
||||
if err := NewTempAuthKeyBindingStore(pool).Save(ctx, domain.TempAuthKeyBinding{
|
||||
TempAuthKeyID: tempForTwo,
|
||||
PermAuthKeyID: authKeyIDToInt64(revokedTwo),
|
||||
Nonce: 3,
|
||||
TempSessionID: 4,
|
||||
ExpiresAt: int(time.Now().Add(time.Hour).Unix()),
|
||||
ExpiresAt: tempExpiry,
|
||||
EncryptedMessage: []byte{5, 6, 7, 8},
|
||||
}); err != nil {
|
||||
t.Fatalf("save temp binding: %v", err)
|
||||
|
|
@ -117,7 +119,7 @@ func TestAuthorizationStoreUpdateClientInfoMergesPostgres(t *testing.T) {
|
|||
id := revokeTestAuthKeyID(0xb1)
|
||||
keys := NewAuthKeyStore(pool)
|
||||
auths := NewAuthorizationStore(pool)
|
||||
saveRevokeTestAuthKey(t, ctx, keys, id)
|
||||
saveRevokeTestAuthKey(t, ctx, keys, id, 0)
|
||||
if err := auths.Bind(ctx, domain.Authorization{
|
||||
AuthKeyID: id,
|
||||
UserID: userID,
|
||||
|
|
@ -153,6 +155,382 @@ func TestAuthorizationStoreUpdateClientInfoMergesPostgres(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestAuthorizationStoreRevokeByHashConcurrentTempBindLeavesNoDanglingStatePostgres(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
userID := createRevokeTestUser(t, ctx, pool, "bind-revoke-race")
|
||||
keys := NewAuthKeyStore(pool)
|
||||
auths := NewAuthorizationStore(pool)
|
||||
bindings := NewTempAuthKeyBindingStore(pool)
|
||||
|
||||
for attempt := 0; attempt < 24; attempt++ {
|
||||
tempExpiry := int(time.Now().Add(time.Hour).Unix()) + attempt
|
||||
perm := saveTempIdentityTestAuthKey(t, ctx, pool, keys, 0)
|
||||
temp := saveTempIdentityTestAuthKey(t, ctx, pool, keys, tempExpiry)
|
||||
hash := int64(9300 + attempt)
|
||||
if err := auths.Bind(ctx, domain.Authorization{
|
||||
AuthKeyID: perm,
|
||||
UserID: userID,
|
||||
Hash: hash,
|
||||
}); err != nil {
|
||||
t.Fatalf("attempt %d bind authorization: %v", attempt, err)
|
||||
}
|
||||
candidate := domain.TempAuthKeyBinding{
|
||||
TempAuthKeyID: temp,
|
||||
PermAuthKeyID: authKeyIDToInt64(perm),
|
||||
Nonce: int64(700 + attempt),
|
||||
TempSessionID: int64(800 + attempt),
|
||||
ExpiresAt: tempExpiry,
|
||||
EncryptedMessage: []byte("bind-revoke race"),
|
||||
}
|
||||
|
||||
start := make(chan struct{})
|
||||
bindResult := make(chan error, 1)
|
||||
type revokeResult struct {
|
||||
found bool
|
||||
err error
|
||||
}
|
||||
revokeResults := make(chan revokeResult, 1)
|
||||
go func() {
|
||||
<-start
|
||||
bindResult <- bindings.Save(ctx, candidate)
|
||||
}()
|
||||
go func() {
|
||||
<-start
|
||||
_, found, err := auths.RevokeByHash(ctx, userID, hash)
|
||||
revokeResults <- revokeResult{found: found, err: err}
|
||||
}()
|
||||
close(start)
|
||||
|
||||
bindErr := <-bindResult
|
||||
if bindErr != nil && !errors.Is(bindErr, store.ErrAuthKeyBindingInvalid) {
|
||||
t.Fatalf("attempt %d bind/revoke race bind error = %v", attempt, bindErr)
|
||||
}
|
||||
revoked := <-revokeResults
|
||||
if revoked.err != nil || !revoked.found {
|
||||
t.Fatalf("attempt %d bind/revoke race found=%v err=%v", attempt, revoked.found, revoked.err)
|
||||
}
|
||||
if _, found, err := bindings.GetByTemp(ctx, temp); err != nil || found {
|
||||
t.Fatalf("attempt %d dangling binding found=%v err=%v", attempt, found, err)
|
||||
}
|
||||
assertRevokeTestMissingAuthKey(t, ctx, keys, perm)
|
||||
assertRevokeTestNoAuthorization(t, ctx, auths, perm)
|
||||
if bindErr == nil {
|
||||
assertRevokeTestMissingAuthKey(t, ctx, keys, temp)
|
||||
} else {
|
||||
assertTempIdentityAuthKeyExpiry(t, ctx, keys, temp, tempExpiry)
|
||||
assertRevokeTestNoAuthorization(t, ctx, auths, temp)
|
||||
if err := keys.Delete(ctx, temp); err != nil {
|
||||
t.Fatalf("attempt %d clean unbound loser temp: %v", attempt, err)
|
||||
}
|
||||
assertRevokeTestMissingAuthKey(t, ctx, keys, temp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthorizationStoreRevokeByHashSkipsKeyTransferredAfterCandidateReadPostgres(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
testCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
keys := NewAuthKeyStore(pool)
|
||||
auths := NewAuthorizationStore(pool)
|
||||
bindings := NewTempAuthKeyBindingStore(pool)
|
||||
states := NewUpdateStateStore(pool)
|
||||
userA := createRevokeTestUser(t, testCtx, pool, "hash-owner-a")
|
||||
userB := createRevokeTestUser(t, testCtx, pool, "hash-owner-b")
|
||||
|
||||
tempExpiry := int(time.Now().Add(time.Hour).Unix())
|
||||
perm := saveTempIdentityTestAuthKey(t, testCtx, pool, keys, 0)
|
||||
temp := saveTempIdentityTestAuthKey(t, testCtx, pool, keys, tempExpiry)
|
||||
const (
|
||||
hashA = int64(9501)
|
||||
hashB = int64(9502)
|
||||
)
|
||||
if err := auths.Bind(testCtx, domain.Authorization{
|
||||
AuthKeyID: perm,
|
||||
UserID: userA,
|
||||
Hash: hashA,
|
||||
}); err != nil {
|
||||
t.Fatalf("bind original A authorization: %v", err)
|
||||
}
|
||||
binding := domain.TempAuthKeyBinding{
|
||||
TempAuthKeyID: temp,
|
||||
PermAuthKeyID: authKeyIDToInt64(perm),
|
||||
Nonce: 951,
|
||||
TempSessionID: 952,
|
||||
ExpiresAt: tempExpiry,
|
||||
EncryptedMessage: []byte("owner-transfer binding"),
|
||||
}
|
||||
if err := bindings.Save(testCtx, binding); err != nil {
|
||||
t.Fatalf("save temp binding before owner transfer: %v", err)
|
||||
}
|
||||
|
||||
// Bind B performs the auth_keys-first ownership change inside an open
|
||||
// transaction. Its uncommitted row is invisible to A's candidate lookup, but
|
||||
// the parent FOR UPDATE lock is the deterministic barrier for revocation.
|
||||
bindB, err := pool.Begin(testCtx)
|
||||
if err != nil {
|
||||
t.Fatalf("begin B bind transaction: %v", err)
|
||||
}
|
||||
defer func() { _ = bindB.Rollback(context.Background()) }()
|
||||
wantB := domain.Authorization{
|
||||
AuthKeyID: perm,
|
||||
UserID: userB,
|
||||
Hash: hashB,
|
||||
Layer: 227,
|
||||
DeviceModel: "owner-b-device",
|
||||
Platform: "android",
|
||||
SystemVersion: "test",
|
||||
APIID: 100,
|
||||
AppVersion: "owner-transfer",
|
||||
IP: "127.0.0.2",
|
||||
PasswordPending: true,
|
||||
}
|
||||
if err := bindAuthorization(testCtx, bindB, wantB); err != nil {
|
||||
t.Fatalf("stage B ownership transfer: %v", err)
|
||||
}
|
||||
wantBStored, found, err := NewAuthorizationStore(bindB).ByAuthKey(testCtx, perm)
|
||||
if err != nil || !found {
|
||||
t.Fatalf("read staged B authorization found=%v err=%v", found, err)
|
||||
}
|
||||
wantBState, found, err := NewUpdateStateStore(bindB).Get(testCtx, perm, userB)
|
||||
if err != nil || !found {
|
||||
t.Fatalf("read staged B update state found=%v err=%v", found, err)
|
||||
}
|
||||
|
||||
revokeConn, err := pool.Acquire(testCtx)
|
||||
if err != nil {
|
||||
t.Fatalf("acquire dedicated revoke connection: %v", err)
|
||||
}
|
||||
t.Cleanup(revokeConn.Release)
|
||||
var revokePID int
|
||||
if err := revokeConn.QueryRow(testCtx, "SELECT pg_backend_pid()").Scan(&revokePID); err != nil {
|
||||
t.Fatalf("get revoke backend pid: %v", err)
|
||||
}
|
||||
|
||||
type revokeResult struct {
|
||||
a domain.Authorization
|
||||
found bool
|
||||
err error
|
||||
}
|
||||
revokeResults := make(chan revokeResult, 1)
|
||||
go func() {
|
||||
a, found, err := NewAuthorizationStore(revokeConn).RevokeByHash(testCtx, userA, hashA)
|
||||
revokeResults <- revokeResult{a: a, found: found, err: err}
|
||||
}()
|
||||
waitForPostgresBackendLockWait(t, testCtx, pool, revokePID)
|
||||
|
||||
// Lock wait proves A's revoke already read its candidate and is now serialized
|
||||
// behind Bind B. After B commits, the fresh owner/hash revalidation must omit
|
||||
// the key instead of deleting B through A's stale candidate.
|
||||
if err := bindB.Commit(testCtx); err != nil {
|
||||
t.Fatalf("commit B ownership transfer: %v", err)
|
||||
}
|
||||
|
||||
var revoked revokeResult
|
||||
select {
|
||||
case revoked = <-revokeResults:
|
||||
case <-testCtx.Done():
|
||||
t.Fatalf("revoke did not finish after releasing FK barrier: %v", testCtx.Err())
|
||||
}
|
||||
if revoked.err != nil || revoked.found {
|
||||
t.Fatalf("stale A revoke after B transfer found=%v err=%v, want not found", revoked.found, revoked.err)
|
||||
}
|
||||
if revoked.a != (domain.Authorization{}) {
|
||||
t.Fatalf("stale A revoke returned authorization %+v, want zero", revoked.a)
|
||||
}
|
||||
|
||||
assertRevokeTestPresentAuthKey(t, testCtx, keys, perm)
|
||||
assertRevokeTestPresentAuthKey(t, testCtx, keys, temp)
|
||||
assertTempIdentityBinding(t, testCtx, bindings, binding)
|
||||
gotB, found, err := auths.ByAuthKey(testCtx, perm)
|
||||
if err != nil || !found || gotB != wantBStored {
|
||||
t.Fatalf("B authorization after stale A revoke = %+v found=%v err=%v, want %+v", gotB, found, err, wantBStored)
|
||||
}
|
||||
gotBState, found, err := states.Get(testCtx, perm, userB)
|
||||
if err != nil || !found || gotBState != wantBState {
|
||||
t.Fatalf("B update state after stale A revoke = %+v found=%v err=%v, want %+v", gotBState, found, err, wantBState)
|
||||
}
|
||||
if _, found, err := states.Get(testCtx, perm, userA); err != nil || found {
|
||||
t.Fatalf("stale A update state found=%v err=%v, want absent after B bind", found, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthorizationStoreRevokeByUserExceptPartiallySkipsTransferredCandidatePostgres(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
testCtx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
keys := NewAuthKeyStore(pool)
|
||||
auths := NewAuthorizationStore(pool)
|
||||
bindings := NewTempAuthKeyBindingStore(pool)
|
||||
states := NewUpdateStateStore(pool)
|
||||
userA := createRevokeTestUser(t, testCtx, pool, "bulk-owner-a")
|
||||
userB := createRevokeTestUser(t, testCtx, pool, "bulk-owner-b")
|
||||
|
||||
keep := saveTempIdentityTestAuthKey(t, testCtx, pool, keys, 0)
|
||||
transferred := saveTempIdentityTestAuthKey(t, testCtx, pool, keys, 0)
|
||||
revoked := saveTempIdentityTestAuthKey(t, testCtx, pool, keys, 0)
|
||||
transferredExpiry := int(time.Now().Add(time.Hour).Unix())
|
||||
revokedExpiry := transferredExpiry + 1
|
||||
transferredTemp := saveTempIdentityTestAuthKey(t, testCtx, pool, keys, transferredExpiry)
|
||||
revokedTemp := saveTempIdentityTestAuthKey(t, testCtx, pool, keys, revokedExpiry)
|
||||
|
||||
authorizationsA := []domain.Authorization{
|
||||
{AuthKeyID: keep, UserID: userA, Hash: 9601, DeviceModel: "keep-a"},
|
||||
{AuthKeyID: transferred, UserID: userA, Hash: 9602, DeviceModel: "transfer-from-a"},
|
||||
{AuthKeyID: revoked, UserID: userA, Hash: 9603, DeviceModel: "revoke-a", PasswordPending: true},
|
||||
}
|
||||
for _, authorization := range authorizationsA {
|
||||
if err := auths.Bind(testCtx, authorization); err != nil {
|
||||
t.Fatalf("bind A authorization %x: %v", authorization.AuthKeyID, err)
|
||||
}
|
||||
}
|
||||
transferredBinding := domain.TempAuthKeyBinding{
|
||||
TempAuthKeyID: transferredTemp,
|
||||
PermAuthKeyID: authKeyIDToInt64(transferred),
|
||||
Nonce: 961,
|
||||
TempSessionID: 962,
|
||||
ExpiresAt: transferredExpiry,
|
||||
EncryptedMessage: []byte("transferred candidate binding"),
|
||||
}
|
||||
revokedBinding := domain.TempAuthKeyBinding{
|
||||
TempAuthKeyID: revokedTemp,
|
||||
PermAuthKeyID: authKeyIDToInt64(revoked),
|
||||
Nonce: 963,
|
||||
TempSessionID: 964,
|
||||
ExpiresAt: revokedExpiry,
|
||||
EncryptedMessage: []byte("revoked candidate binding"),
|
||||
}
|
||||
for _, binding := range []domain.TempAuthKeyBinding{transferredBinding, revokedBinding} {
|
||||
if err := bindings.Save(testCtx, binding); err != nil {
|
||||
t.Fatalf("save candidate binding for perm %d: %v", binding.PermAuthKeyID, err)
|
||||
}
|
||||
}
|
||||
|
||||
wantKeepKey, found, err := keys.Get(testCtx, keep)
|
||||
if err != nil || !found {
|
||||
t.Fatalf("read keep key before revoke found=%v err=%v", found, err)
|
||||
}
|
||||
wantKeepAuth, found, err := auths.ByAuthKey(testCtx, keep)
|
||||
if err != nil || !found {
|
||||
t.Fatalf("read keep authorization before revoke found=%v err=%v", found, err)
|
||||
}
|
||||
wantKeepState, found, err := states.Get(testCtx, keep, userA)
|
||||
if err != nil || !found {
|
||||
t.Fatalf("read keep state before revoke found=%v err=%v", found, err)
|
||||
}
|
||||
wantRevokedAuth, found, err := auths.ByAuthKey(testCtx, revoked)
|
||||
if err != nil || !found {
|
||||
t.Fatalf("read revocable authorization before revoke found=%v err=%v", found, err)
|
||||
}
|
||||
|
||||
bindB, err := pool.Begin(testCtx)
|
||||
if err != nil {
|
||||
t.Fatalf("begin partial owner-transfer transaction: %v", err)
|
||||
}
|
||||
defer func() { _ = bindB.Rollback(context.Background()) }()
|
||||
wantB := domain.Authorization{
|
||||
AuthKeyID: transferred,
|
||||
UserID: userB,
|
||||
Hash: 9604,
|
||||
Layer: 227,
|
||||
DeviceModel: "bulk-owner-b",
|
||||
Platform: "android",
|
||||
SystemVersion: "test",
|
||||
APIID: 100,
|
||||
AppVersion: "partial-owner-transfer",
|
||||
IP: "127.0.0.3",
|
||||
}
|
||||
if err := bindAuthorization(testCtx, bindB, wantB); err != nil {
|
||||
t.Fatalf("stage partial B ownership transfer: %v", err)
|
||||
}
|
||||
wantBStored, found, err := NewAuthorizationStore(bindB).ByAuthKey(testCtx, transferred)
|
||||
if err != nil || !found {
|
||||
t.Fatalf("read staged partial B authorization found=%v err=%v", found, err)
|
||||
}
|
||||
wantBState, found, err := NewUpdateStateStore(bindB).Get(testCtx, transferred, userB)
|
||||
if err != nil || !found {
|
||||
t.Fatalf("read staged partial B state found=%v err=%v", found, err)
|
||||
}
|
||||
|
||||
revokeConn, err := pool.Acquire(testCtx)
|
||||
if err != nil {
|
||||
t.Fatalf("acquire dedicated bulk revoke connection: %v", err)
|
||||
}
|
||||
t.Cleanup(revokeConn.Release)
|
||||
var revokePID int
|
||||
if err := revokeConn.QueryRow(testCtx, "SELECT pg_backend_pid()").Scan(&revokePID); err != nil {
|
||||
t.Fatalf("get bulk revoke backend pid: %v", err)
|
||||
}
|
||||
type bulkRevokeResult struct {
|
||||
deleted []domain.Authorization
|
||||
err error
|
||||
}
|
||||
revokeResults := make(chan bulkRevokeResult, 1)
|
||||
go func() {
|
||||
deleted, err := NewAuthorizationStore(revokeConn).RevokeByUserExcept(testCtx, userA, keep)
|
||||
revokeResults <- bulkRevokeResult{deleted: deleted, err: err}
|
||||
}()
|
||||
waitForPostgresBackendLockWait(t, testCtx, pool, revokePID)
|
||||
|
||||
// The bulk candidate list now contains both old A keys. Releasing B's parent
|
||||
// lock forces a fresh owner revalidation: transferred must be omitted while
|
||||
// the unrelated candidate that still belongs to A remains revocable.
|
||||
if err := bindB.Commit(testCtx); err != nil {
|
||||
t.Fatalf("commit partial B ownership transfer: %v", err)
|
||||
}
|
||||
var result bulkRevokeResult
|
||||
select {
|
||||
case result = <-revokeResults:
|
||||
case <-testCtx.Done():
|
||||
t.Fatalf("bulk revoke did not finish after owner transfer: %v", testCtx.Err())
|
||||
}
|
||||
if result.err != nil {
|
||||
t.Fatalf("bulk revoke after partial owner transfer: %v", result.err)
|
||||
}
|
||||
if len(result.deleted) != 1 || result.deleted[0] != wantRevokedAuth {
|
||||
t.Fatalf("bulk revoked authorizations = %+v, want only %+v", result.deleted, wantRevokedAuth)
|
||||
}
|
||||
|
||||
gotKeepKey, found, err := keys.Get(testCtx, keep)
|
||||
if err != nil || !found || gotKeepKey != wantKeepKey {
|
||||
t.Fatalf("keep key after bulk revoke = %+v found=%v err=%v, want unchanged", gotKeepKey, found, err)
|
||||
}
|
||||
gotKeepAuth, found, err := auths.ByAuthKey(testCtx, keep)
|
||||
if err != nil || !found || gotKeepAuth != wantKeepAuth {
|
||||
t.Fatalf("keep authorization after bulk revoke = %+v found=%v err=%v, want %+v", gotKeepAuth, found, err, wantKeepAuth)
|
||||
}
|
||||
gotKeepState, found, err := states.Get(testCtx, keep, userA)
|
||||
if err != nil || !found || gotKeepState != wantKeepState {
|
||||
t.Fatalf("keep state after bulk revoke = %+v found=%v err=%v, want %+v", gotKeepState, found, err, wantKeepState)
|
||||
}
|
||||
|
||||
assertRevokeTestPresentAuthKey(t, testCtx, keys, transferred)
|
||||
assertRevokeTestPresentAuthKey(t, testCtx, keys, transferredTemp)
|
||||
assertTempIdentityBinding(t, testCtx, bindings, transferredBinding)
|
||||
gotB, found, err := auths.ByAuthKey(testCtx, transferred)
|
||||
if err != nil || !found || gotB != wantBStored {
|
||||
t.Fatalf("transferred B authorization = %+v found=%v err=%v, want %+v", gotB, found, err, wantBStored)
|
||||
}
|
||||
gotBState, found, err := states.Get(testCtx, transferred, userB)
|
||||
if err != nil || !found || gotBState != wantBState {
|
||||
t.Fatalf("transferred B state = %+v found=%v err=%v, want %+v", gotBState, found, err, wantBState)
|
||||
}
|
||||
if _, found, err := states.Get(testCtx, transferred, userA); err != nil || found {
|
||||
t.Fatalf("old A state for transferred key found=%v err=%v, want absent", found, err)
|
||||
}
|
||||
|
||||
assertRevokeTestMissingAuthKey(t, testCtx, keys, revoked)
|
||||
assertRevokeTestMissingAuthKey(t, testCtx, keys, revokedTemp)
|
||||
assertRevokeTestNoAuthorization(t, testCtx, auths, revoked)
|
||||
if _, found, err := bindings.GetByTemp(testCtx, revokedTemp); err != nil || found {
|
||||
t.Fatalf("revoked temp binding found=%v err=%v, want absent", found, err)
|
||||
}
|
||||
if _, found, err := states.Get(testCtx, revoked, userA); err != nil || found {
|
||||
t.Fatalf("revoked A state found=%v err=%v, want absent", found, err)
|
||||
}
|
||||
}
|
||||
|
||||
func createRevokeTestUser(t *testing.T, ctx context.Context, db *pgxpool.Pool, suffix string) int64 {
|
||||
t.Helper()
|
||||
phone := fmt.Sprintf("+1555%09d", time.Now().UnixNano()%1_000_000_000)
|
||||
|
|
@ -173,9 +551,9 @@ func revokeTestAuthKeyID(seed byte) [8]byte {
|
|||
return [8]byte{seed, seed, seed, seed, seed, seed, seed, seed}
|
||||
}
|
||||
|
||||
func saveRevokeTestAuthKey(t *testing.T, ctx context.Context, keys store.AuthKeyStore, id [8]byte) {
|
||||
func saveRevokeTestAuthKey(t *testing.T, ctx context.Context, keys store.AuthKeyStore, id [8]byte, expiresAt int) {
|
||||
t.Helper()
|
||||
if err := keys.Save(ctx, store.AuthKeyData{ID: id, ServerSalt: int64(id[0])}); err != nil {
|
||||
if err := keys.Save(ctx, store.AuthKeyData{ID: id, ServerSalt: int64(id[0]), ExpiresAt: expiresAt}); err != nil {
|
||||
t.Fatalf("save auth key %x: %v", id, err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
|
|
|
|||
|
|
@ -205,7 +205,7 @@ func TestBotStoreRoundTripPostgres(t *testing.T) {
|
|||
appauth.WithBotLogin(bots))
|
||||
var authKeyID [8]byte
|
||||
copy(authKeyID[:], fmt.Sprintf("%08d", suffix%100000000))
|
||||
if _, err := pool.Exec(ctx, "INSERT INTO auth_keys (auth_key_id, body, server_salt) VALUES ($1, $2, 0) ON CONFLICT DO NOTHING",
|
||||
if _, err := pool.Exec(ctx, "INSERT INTO auth_keys (auth_key_id, body, server_salt, expires_at) VALUES ($1, $2, 0, 0) ON CONFLICT DO NOTHING",
|
||||
authKeyIDToInt64(authKeyID), make([]byte, 256)); err != nil {
|
||||
t.Fatalf("seed auth key: %v", err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import (
|
|||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
|
|
@ -26,15 +27,32 @@ func TestBusinessStoresRoundTrip(t *testing.T) {
|
|||
if _, err := rand.Read(authBody[:]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := NewAuthKeyStore(pool).Save(ctx, store.AuthKeyData{
|
||||
authExpiry := int(time.Now().Add(time.Hour).Unix())
|
||||
keys := NewAuthKeyStore(pool)
|
||||
if err := keys.Save(ctx, store.AuthKeyData{
|
||||
ID: authID,
|
||||
Value: authBody,
|
||||
ServerSalt: 42,
|
||||
ExpiresAt: authExpiry,
|
||||
}); err != nil {
|
||||
t.Fatalf("save auth key: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(ctx, "DELETE FROM auth_keys WHERE auth_key_id = $1", authKeyIDToInt64(authID))
|
||||
_ = keys.Delete(ctx, authID)
|
||||
})
|
||||
var permAuthID [8]byte
|
||||
var permAuthBody [256]byte
|
||||
if _, err := rand.Read(permAuthID[:]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := rand.Read(permAuthBody[:]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := keys.Save(ctx, store.AuthKeyData{ID: permAuthID, Value: permAuthBody}); err != nil {
|
||||
t.Fatalf("save permanent auth key: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = keys.Delete(ctx, permAuthID)
|
||||
})
|
||||
|
||||
users := NewUserStore(pool)
|
||||
|
|
@ -164,10 +182,10 @@ func TestBusinessStoresRoundTrip(t *testing.T) {
|
|||
|
||||
if err := NewTempAuthKeyBindingStore(pool).Save(ctx, domain.TempAuthKeyBinding{
|
||||
TempAuthKeyID: authID,
|
||||
PermAuthKeyID: 12345,
|
||||
PermAuthKeyID: authKeyIDToInt64(permAuthID),
|
||||
Nonce: 67890,
|
||||
TempSessionID: 24680,
|
||||
ExpiresAt: 111,
|
||||
ExpiresAt: authExpiry,
|
||||
EncryptedMessage: []byte("binding"),
|
||||
}); err != nil {
|
||||
t.Fatalf("save temp auth key binding: %v", err)
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package postgres
|
|||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
|
|
@ -68,6 +69,68 @@ func (s *LangPackStore) GetStrings(ctx context.Context, langPack, langCode strin
|
|||
return meta, nil
|
||||
}
|
||||
|
||||
func (s *LangPackStore) ListLanguages(ctx context.Context, langPack string) ([]domain.LangPackLanguage, error) {
|
||||
rows, err := s.q.ListLangPackLanguages(ctx, langPack)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list lang pack languages: %w", err)
|
||||
}
|
||||
out := make([]domain.LangPackLanguage, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
out = append(out, domain.LangPackLanguage{
|
||||
LangPack: row.LangPack,
|
||||
LangCode: row.LangCode,
|
||||
Name: row.Name,
|
||||
NativeName: row.NativeName,
|
||||
StringsCount: int(row.StringsCount),
|
||||
TranslatedCount: int(row.StringsCount),
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *LangPackStore) GetSeedCatalog(ctx context.Context, catalog string) (domain.LangPackSeedCatalog, error) {
|
||||
if catalog == "" {
|
||||
catalog = "default"
|
||||
}
|
||||
encoded, err := s.q.GetLangPackSeedHash(ctx, langPackSeedManifestStateKey(catalog))
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.LangPackSeedCatalog{Catalog: catalog}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return domain.LangPackSeedCatalog{}, fmt.Errorf("get langpack seed catalog %q: %w", catalog, err)
|
||||
}
|
||||
var state domain.LangPackSeedCatalog
|
||||
if err := json.Unmarshal([]byte(encoded), &state); err != nil {
|
||||
return domain.LangPackSeedCatalog{}, fmt.Errorf("decode langpack seed catalog %q: %w", catalog, err)
|
||||
}
|
||||
if state.Catalog != catalog {
|
||||
return domain.LangPackSeedCatalog{}, fmt.Errorf("langpack seed catalog key %q contains catalog %q", catalog, state.Catalog)
|
||||
}
|
||||
return state, nil
|
||||
}
|
||||
|
||||
func (s *LangPackStore) ReconcileSeed(ctx context.Context, seed domain.LangPackSeed) (int, error) {
|
||||
txer, ok := s.db.(interface {
|
||||
Begin(context.Context) (pgx.Tx, error)
|
||||
})
|
||||
if !ok {
|
||||
return 0, errors.New("langpack seed reconciliation requires transaction support")
|
||||
}
|
||||
tx, err := txer.Begin(ctx)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("begin langpack seed reconciliation: %w", err)
|
||||
}
|
||||
written, err := reconcileSeedWith(ctx, tx, s.q.WithTx(tx), seed)
|
||||
if err != nil {
|
||||
_ = tx.Rollback(ctx)
|
||||
return 0, err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return 0, fmt.Errorf("commit langpack seed reconciliation: %w", err)
|
||||
}
|
||||
return written, nil
|
||||
}
|
||||
|
||||
func (s *LangPackStore) UpsertPack(ctx context.Context, pack domain.LangPack) error {
|
||||
if txer, ok := s.db.(interface {
|
||||
Begin(context.Context) (pgx.Tx, error)
|
||||
|
|
@ -77,7 +140,15 @@ func (s *LangPackStore) UpsertPack(ctx context.Context, pack domain.LangPack) er
|
|||
return fmt.Errorf("begin lang pack upsert: %w", err)
|
||||
}
|
||||
q := s.q.WithTx(tx)
|
||||
if err := upsertPackWith(ctx, q, pack); err != nil {
|
||||
if err := replacePackWithCopy(ctx, tx, q, pack); err != nil {
|
||||
_ = tx.Rollback(ctx)
|
||||
return err
|
||||
}
|
||||
if err := q.DeleteLangPackSeedHash(ctx, langPackSeedStateKey(pack.LangPack, pack.LangCode)); err != nil {
|
||||
_ = tx.Rollback(ctx)
|
||||
return fmt.Errorf("delete lang pack seed hash: %w", err)
|
||||
}
|
||||
if err := invalidateLangPackSeedCatalogs(ctx, tx); err != nil {
|
||||
_ = tx.Rollback(ctx)
|
||||
return err
|
||||
}
|
||||
|
|
@ -86,10 +157,189 @@ func (s *LangPackStore) UpsertPack(ctx context.Context, pack domain.LangPack) er
|
|||
}
|
||||
return nil
|
||||
}
|
||||
return upsertPackWith(ctx, s.q, pack)
|
||||
if err := replacePackWithQueries(ctx, s.q, pack); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.q.DeleteLangPackSeedHash(ctx, langPackSeedStateKey(pack.LangPack, pack.LangCode)); err != nil {
|
||||
return fmt.Errorf("delete lang pack seed hash: %w", err)
|
||||
}
|
||||
if err := invalidateLangPackSeedCatalogs(ctx, s.db); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func upsertPackWith(ctx context.Context, q *sqlcgen.Queries, pack domain.LangPack) error {
|
||||
func reconcileSeedWith(ctx context.Context, tx pgx.Tx, q *sqlcgen.Queries, seed domain.LangPackSeed) (int, error) {
|
||||
catalog := seed.Catalog
|
||||
if catalog == "" {
|
||||
catalog = "default"
|
||||
}
|
||||
reconciledScopes := append([]string(nil), seed.Scopes...)
|
||||
scopes := make(map[string]struct{}, len(seed.Scopes))
|
||||
wanted := make(map[string]map[string]struct{}, len(seed.Scopes))
|
||||
for _, scope := range seed.Scopes {
|
||||
scopes[scope] = struct{}{}
|
||||
wanted[scope] = make(map[string]struct{})
|
||||
}
|
||||
previousScopesJSON, err := q.GetLangPackSeedHash(ctx, langPackSeedCatalogStateKey(catalog))
|
||||
if err == nil {
|
||||
var previousScopes []string
|
||||
if err := json.Unmarshal([]byte(previousScopesJSON), &previousScopes); err != nil {
|
||||
return 0, fmt.Errorf("decode previous langpack seed scopes for %q: %w", catalog, err)
|
||||
}
|
||||
for _, scope := range previousScopes {
|
||||
if _, exists := scopes[scope]; exists {
|
||||
continue
|
||||
}
|
||||
scopes[scope] = struct{}{}
|
||||
wanted[scope] = make(map[string]struct{})
|
||||
reconciledScopes = append(reconciledScopes, scope)
|
||||
}
|
||||
} else if !errors.Is(err, pgx.ErrNoRows) {
|
||||
return 0, fmt.Errorf("get previous langpack seed scopes for %q: %w", catalog, err)
|
||||
}
|
||||
for _, entry := range seed.Packs {
|
||||
if err := validateSeedEntry(entry); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if _, ok := scopes[entry.Pack.LangPack]; !ok {
|
||||
return 0, fmt.Errorf("seeded langpack %s/%s is outside reconciliation scopes", entry.Pack.LangPack, entry.Pack.LangCode)
|
||||
}
|
||||
if _, exists := wanted[entry.Pack.LangPack][entry.Pack.LangCode]; exists {
|
||||
return 0, fmt.Errorf("duplicate seeded langpack %s/%s", entry.Pack.LangPack, entry.Pack.LangCode)
|
||||
}
|
||||
wanted[entry.Pack.LangPack][entry.Pack.LangCode] = struct{}{}
|
||||
}
|
||||
|
||||
for _, scope := range reconciledScopes {
|
||||
codes, err := q.ListLangPackCodes(ctx, scope)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("list existing langpack codes for %q: %w", scope, err)
|
||||
}
|
||||
for _, code := range codes {
|
||||
if _, keep := wanted[scope][code]; keep {
|
||||
continue
|
||||
}
|
||||
params := sqlcgen.DeleteLangPackStringsParams{LangPack: scope, LangCode: code}
|
||||
if err := q.DeleteLangPackStrings(ctx, params); err != nil {
|
||||
return 0, fmt.Errorf("delete removed langpack strings %s/%s: %w", scope, code, err)
|
||||
}
|
||||
if err := q.DeleteLangPackMeta(ctx, sqlcgen.DeleteLangPackMetaParams(params)); err != nil {
|
||||
return 0, fmt.Errorf("delete removed langpack metadata %s/%s: %w", scope, code, err)
|
||||
}
|
||||
if err := q.DeleteLangPackSeedHash(ctx, langPackSeedStateKey(scope, code)); err != nil {
|
||||
return 0, fmt.Errorf("delete removed langpack seed hash %s/%s: %w", scope, code, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
written := 0
|
||||
for _, entry := range seed.Packs {
|
||||
pack := entry.Pack
|
||||
meta, metaErr := q.GetLangPackMeta(ctx, sqlcgen.GetLangPackMetaParams{
|
||||
LangPack: pack.LangPack,
|
||||
LangCode: pack.LangCode,
|
||||
})
|
||||
metaFound := metaErr == nil
|
||||
if metaErr != nil && !errors.Is(metaErr, pgx.ErrNoRows) {
|
||||
return 0, fmt.Errorf("get existing langpack metadata %s/%s: %w", pack.LangPack, pack.LangCode, metaErr)
|
||||
}
|
||||
if metaFound && pack.Version < int(meta.Version) {
|
||||
return 0, fmt.Errorf("langpack version rollback %s/%s: %d < %d", pack.LangPack, pack.LangCode, pack.Version, meta.Version)
|
||||
}
|
||||
|
||||
stateKey := langPackSeedStateKey(pack.LangPack, pack.LangCode)
|
||||
oldHash, hashErr := q.GetLangPackSeedHash(ctx, stateKey)
|
||||
hashFound := hashErr == nil
|
||||
if hashErr != nil && !errors.Is(hashErr, pgx.ErrNoRows) {
|
||||
return 0, fmt.Errorf("get langpack seed hash %s/%s: %w", pack.LangPack, pack.LangCode, hashErr)
|
||||
}
|
||||
if metaFound && hashFound && pack.Version == int(meta.Version) && oldHash != entry.ContentHash {
|
||||
return 0, fmt.Errorf("langpack %s/%s v%d content changed without version bump", pack.LangPack, pack.LangCode, pack.Version)
|
||||
}
|
||||
if metaFound && hashFound && pack.Version == int(meta.Version) && int(meta.StringsCount) == entry.StringsCount && oldHash == entry.ContentHash {
|
||||
continue
|
||||
}
|
||||
if !entry.ContentLoaded {
|
||||
return 0, fmt.Errorf("langpack %s/%s content is required but was not loaded", pack.LangPack, pack.LangCode)
|
||||
}
|
||||
|
||||
if err := replacePackWithCopy(ctx, tx, q, pack); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := q.PutLangPackSeedHash(ctx, sqlcgen.PutLangPackSeedHashParams{Key: stateKey, ContentHash: entry.ContentHash}); err != nil {
|
||||
return 0, fmt.Errorf("put langpack seed hash %s/%s: %w", pack.LangPack, pack.LangCode, err)
|
||||
}
|
||||
written += len(pack.Strings)
|
||||
}
|
||||
encodedScopes, err := json.Marshal(seed.Scopes)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("encode langpack seed scopes for %q: %w", catalog, err)
|
||||
}
|
||||
if err := q.PutLangPackSeedHash(ctx, sqlcgen.PutLangPackSeedHashParams{
|
||||
Key: langPackSeedCatalogStateKey(catalog),
|
||||
ContentHash: string(encodedScopes),
|
||||
}); err != nil {
|
||||
return 0, fmt.Errorf("put langpack seed scopes for %q: %w", catalog, err)
|
||||
}
|
||||
manifest := seedCatalogSnapshot(seed)
|
||||
encodedManifest, err := json.Marshal(manifest)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("encode langpack seed catalog %q: %w", catalog, err)
|
||||
}
|
||||
if err := q.PutLangPackSeedHash(ctx, sqlcgen.PutLangPackSeedHashParams{
|
||||
Key: langPackSeedManifestStateKey(catalog),
|
||||
ContentHash: string(encodedManifest),
|
||||
}); err != nil {
|
||||
return 0, fmt.Errorf("put langpack seed catalog %q: %w", catalog, err)
|
||||
}
|
||||
return written, nil
|
||||
}
|
||||
|
||||
func replacePackWithCopy(ctx context.Context, tx pgx.Tx, q *sqlcgen.Queries, pack domain.LangPack) error {
|
||||
params := sqlcgen.DeleteLangPackStringsParams{LangPack: pack.LangPack, LangCode: pack.LangCode}
|
||||
if err := q.DeleteLangPackStrings(ctx, params); err != nil {
|
||||
return fmt.Errorf("delete previous lang pack strings: %w", err)
|
||||
}
|
||||
if err := q.UpsertLangPackMeta(ctx, sqlcgen.UpsertLangPackMetaParams{
|
||||
LangPack: pack.LangPack,
|
||||
LangCode: pack.LangCode,
|
||||
Version: int32(pack.Version),
|
||||
StringsCount: int32(len(pack.Strings)),
|
||||
}); err != nil {
|
||||
return fmt.Errorf("upsert lang pack meta: %w", err)
|
||||
}
|
||||
if len(pack.Strings) == 0 {
|
||||
return nil
|
||||
}
|
||||
count, err := tx.CopyFrom(
|
||||
ctx,
|
||||
pgx.Identifier{"lang_pack_strings"},
|
||||
[]string{
|
||||
"lang_pack", "lang_code", "key", "version", "pluralized", "value",
|
||||
"zero_value", "one_value", "two_value", "few_value", "many_value", "other_value", "deleted",
|
||||
},
|
||||
pgx.CopyFromSlice(len(pack.Strings), func(i int) ([]any, error) {
|
||||
item := pack.Strings[i]
|
||||
return []any{
|
||||
pack.LangPack, pack.LangCode, item.Key, int32(pack.Version), item.Pluralized, item.Value,
|
||||
item.ZeroValue, item.OneValue, item.TwoValue, item.FewValue, item.ManyValue, item.OtherValue, item.Deleted,
|
||||
}, nil
|
||||
}),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("copy lang pack strings: %w", err)
|
||||
}
|
||||
if count != int64(len(pack.Strings)) {
|
||||
return fmt.Errorf("copy lang pack strings wrote %d rows, want %d", count, len(pack.Strings))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func replacePackWithQueries(ctx context.Context, q *sqlcgen.Queries, pack domain.LangPack) error {
|
||||
if err := q.DeleteLangPackStrings(ctx, sqlcgen.DeleteLangPackStringsParams{LangPack: pack.LangPack, LangCode: pack.LangCode}); err != nil {
|
||||
return fmt.Errorf("delete previous lang pack strings: %w", err)
|
||||
}
|
||||
if err := q.UpsertLangPackMeta(ctx, sqlcgen.UpsertLangPackMetaParams{
|
||||
LangPack: pack.LangPack,
|
||||
LangCode: pack.LangCode,
|
||||
|
|
@ -120,6 +370,54 @@ func upsertPackWith(ctx context.Context, q *sqlcgen.Queries, pack domain.LangPac
|
|||
return nil
|
||||
}
|
||||
|
||||
func langPackSeedStateKey(langPack, langCode string) string {
|
||||
return "langpack:v1:entry:" + langPack + ":" + langCode
|
||||
}
|
||||
|
||||
func langPackSeedCatalogStateKey(catalog string) string {
|
||||
return "langpack:v1:catalog:" + catalog
|
||||
}
|
||||
|
||||
func langPackSeedManifestStateKey(catalog string) string {
|
||||
return "langpack:v2:catalog:" + catalog
|
||||
}
|
||||
|
||||
func validateSeedEntry(entry domain.LangPackSeedEntry) error {
|
||||
if entry.SourceHash == "" || entry.ContentHash == "" || entry.StringsCount <= 0 {
|
||||
return fmt.Errorf("langpack %s/%s has incomplete seed metadata", entry.Pack.LangPack, entry.Pack.LangCode)
|
||||
}
|
||||
if entry.ContentLoaded && len(entry.Pack.Strings) != entry.StringsCount {
|
||||
return fmt.Errorf("langpack %s/%s loaded %d strings, want %d", entry.Pack.LangPack, entry.Pack.LangCode, len(entry.Pack.Strings), entry.StringsCount)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func seedCatalogSnapshot(seed domain.LangPackSeed) domain.LangPackSeedCatalog {
|
||||
state := domain.LangPackSeedCatalog{
|
||||
Catalog: seed.Catalog,
|
||||
Scopes: append([]string(nil), seed.Scopes...),
|
||||
Packs: make([]domain.LangPackSeedCatalogEntry, 0, len(seed.Packs)),
|
||||
}
|
||||
for _, entry := range seed.Packs {
|
||||
state.Packs = append(state.Packs, domain.LangPackSeedCatalogEntry{
|
||||
LangPack: entry.Pack.LangPack,
|
||||
LangCode: entry.Pack.LangCode,
|
||||
Version: entry.Pack.Version,
|
||||
SourceHash: entry.SourceHash,
|
||||
ContentHash: entry.ContentHash,
|
||||
StringsCount: entry.StringsCount,
|
||||
})
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
func invalidateLangPackSeedCatalogs(ctx context.Context, db sqlcgen.DBTX) error {
|
||||
if _, err := db.Exec(ctx, `DELETE FROM seed_states WHERE key LIKE 'langpack:v2:catalog:%'`); err != nil {
|
||||
return fmt.Errorf("invalidate langpack seed catalogs: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *LangPackStore) meta(ctx context.Context, langPack, langCode string) (domain.LangPack, bool, error) {
|
||||
row, err := s.q.GetLangPackMeta(ctx, sqlcgen.GetLangPackMetaParams{
|
||||
LangPack: langPack,
|
||||
|
|
|
|||
164
internal/store/postgres/langpack_seed_integration_test.go
Normal file
164
internal/store/postgres/langpack_seed_integration_test.go
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
applangpack "telesrv/internal/app/langpack"
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func TestLangPackSeedReconciliationPostgres(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
packName := "seedtest-" + randomSuffix(t)
|
||||
store := NewLangPackStore(pool)
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(ctx, "DELETE FROM lang_pack_strings WHERE lang_pack = $1", packName)
|
||||
_, _ = pool.Exec(ctx, "DELETE FROM lang_packs WHERE lang_pack = $1", packName)
|
||||
_, _ = pool.Exec(ctx, "DELETE FROM seed_states WHERE key LIKE $1 OR key = $2 OR key = $3", "langpack:v1:entry:"+packName+":%", "langpack:v1:catalog:"+packName, "langpack:v2:catalog:"+packName)
|
||||
})
|
||||
|
||||
seedV1 := domain.LangPackSeed{
|
||||
Catalog: packName,
|
||||
Scopes: []string{packName},
|
||||
Packs: []domain.LangPackSeedEntry{{
|
||||
SourceHash: "source-v1",
|
||||
ContentHash: "hash-v1",
|
||||
StringsCount: 2,
|
||||
ContentLoaded: true,
|
||||
Pack: domain.LangPack{
|
||||
LangPack: packName,
|
||||
LangCode: "fr",
|
||||
Version: 1,
|
||||
Strings: []domain.LangPackString{
|
||||
{Key: "LanguageName", Value: "Français"},
|
||||
{Key: "old", Value: "old"},
|
||||
},
|
||||
},
|
||||
}},
|
||||
}
|
||||
written, err := store.ReconcileSeed(ctx, seedV1)
|
||||
if err != nil || written != 2 {
|
||||
t.Fatalf("reconcile v1 = %d, %v", written, err)
|
||||
}
|
||||
seedV1Unloaded := seedV1
|
||||
seedV1Unloaded.Packs = append([]domain.LangPackSeedEntry(nil), seedV1.Packs...)
|
||||
seedV1Unloaded.Packs[0].Pack.Strings = nil
|
||||
seedV1Unloaded.Packs[0].ContentLoaded = false
|
||||
if written, err := store.ReconcileSeed(ctx, seedV1Unloaded); err != nil || written != 0 {
|
||||
t.Fatalf("reconcile unchanged v1 = %d, %v", written, err)
|
||||
}
|
||||
catalog, err := store.GetSeedCatalog(ctx, packName)
|
||||
if err != nil || len(catalog.Packs) != 1 || catalog.Packs[0].SourceHash != "source-v1" {
|
||||
t.Fatalf("seed catalog = %+v, %v", catalog, err)
|
||||
}
|
||||
|
||||
seedV2 := domain.LangPackSeed{
|
||||
Catalog: packName,
|
||||
Scopes: []string{packName},
|
||||
Packs: []domain.LangPackSeedEntry{{
|
||||
SourceHash: "source-v2",
|
||||
ContentHash: "hash-v2",
|
||||
StringsCount: 2,
|
||||
ContentLoaded: true,
|
||||
Pack: domain.LangPack{
|
||||
LangPack: packName,
|
||||
LangCode: "fr",
|
||||
Version: 2,
|
||||
Strings: []domain.LangPackString{
|
||||
{Key: "LanguageName", Value: "Français v2"},
|
||||
{Key: "new", Value: "new"},
|
||||
},
|
||||
},
|
||||
}},
|
||||
}
|
||||
written, err = store.ReconcileSeed(ctx, seedV2)
|
||||
if err != nil || written != 2 {
|
||||
t.Fatalf("reconcile v2 = %d, %v", written, err)
|
||||
}
|
||||
pack, err := store.GetPack(ctx, packName, "fr", 0)
|
||||
if err != nil || pack.Version != 2 || len(pack.Strings) != 2 || postgresLangPackValue(pack.Strings, "old") != "" || postgresLangPackValue(pack.Strings, "new") != "new" {
|
||||
t.Fatalf("replaced postgres pack = %+v, err %v", pack, err)
|
||||
}
|
||||
|
||||
mutated := seedV2
|
||||
mutated.Packs = append([]domain.LangPackSeedEntry(nil), seedV2.Packs...)
|
||||
mutated.Packs[0].ContentHash = "changed-without-version"
|
||||
if _, err := store.ReconcileSeed(ctx, mutated); err == nil || !strings.Contains(err.Error(), "without version bump") {
|
||||
t.Fatalf("same-version mutation error = %v", err)
|
||||
}
|
||||
rollback := seedV2
|
||||
rollback.Packs = append([]domain.LangPackSeedEntry(nil), seedV2.Packs...)
|
||||
rollback.Packs[0].Pack.Version = 1
|
||||
rollback.Packs[0].ContentHash = "rollback"
|
||||
if _, err := store.ReconcileSeed(ctx, rollback); err == nil || !strings.Contains(err.Error(), "version rollback") {
|
||||
t.Fatalf("rollback error = %v", err)
|
||||
}
|
||||
|
||||
if written, err := store.ReconcileSeed(ctx, domain.LangPackSeed{Catalog: packName}); err != nil || written != 0 {
|
||||
t.Fatalf("remove missing language = %d, %v", written, err)
|
||||
}
|
||||
languages, err := store.ListLanguages(ctx, packName)
|
||||
if err != nil || len(languages) != 0 {
|
||||
t.Fatalf("languages after manifest removal = %+v, err %v", languages, err)
|
||||
}
|
||||
}
|
||||
|
||||
func postgresLangPackValue(items []domain.LangPackString, key string) string {
|
||||
for _, item := range items {
|
||||
if item.Key == key {
|
||||
return item.Value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func TestBundledLangPackSeedPostgres(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
scopes := []string{"android", "android_x", "ios", "macos", "tdesktop", "weba"}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(ctx, "DELETE FROM lang_pack_strings WHERE lang_pack = ANY($1::text[])", scopes)
|
||||
_, _ = pool.Exec(ctx, "DELETE FROM lang_packs WHERE lang_pack = ANY($1::text[])", scopes)
|
||||
for _, scope := range scopes {
|
||||
_, _ = pool.Exec(ctx, "DELETE FROM seed_states WHERE key LIKE $1", "langpack:v1:entry:"+scope+":%")
|
||||
}
|
||||
_, _ = pool.Exec(ctx, "DELETE FROM seed_states WHERE key = 'langpack:v1:catalog:langpack'")
|
||||
_, _ = pool.Exec(ctx, "DELETE FROM seed_states WHERE key = 'langpack:v2:catalog:langpack'")
|
||||
})
|
||||
|
||||
service := applangpack.NewService(NewLangPackStore(pool))
|
||||
root := filepath.Join("..", "..", "..", "data", "langpack")
|
||||
started := time.Now()
|
||||
seeded, err := service.SeedDirectory(ctx, root)
|
||||
firstDuration := time.Since(started)
|
||||
if err != nil {
|
||||
t.Fatalf("seed bundled langpacks to postgres: %v", err)
|
||||
}
|
||||
if seeded < 50000 {
|
||||
t.Fatalf("seeded bundled strings = %d, want full catalog", seeded)
|
||||
}
|
||||
|
||||
started = time.Now()
|
||||
seededAgain, err := service.SeedDirectory(ctx, root)
|
||||
secondDuration := time.Since(started)
|
||||
if err != nil || seededAgain != 0 {
|
||||
t.Fatalf("reconcile unchanged bundled langpacks = %d, %v", seededAgain, err)
|
||||
}
|
||||
var metadataCount, activeCount int64
|
||||
if err := pool.QueryRow(ctx, `
|
||||
SELECT
|
||||
(SELECT COALESCE(sum(strings_count), 0) FROM lang_packs WHERE lang_pack = ANY($1::text[])),
|
||||
(SELECT count(*) FROM lang_pack_strings WHERE lang_pack = ANY($1::text[]) AND NOT deleted)
|
||||
`, scopes).Scan(&metadataCount, &activeCount); err != nil {
|
||||
t.Fatalf("count seeded langpack rows: %v", err)
|
||||
}
|
||||
if metadataCount != activeCount {
|
||||
t.Fatalf("seeded strings_count = %d, active rows = %d", metadataCount, activeCount)
|
||||
}
|
||||
t.Logf("bundled langpack seed: first=%s unchanged=%s strings=%d", firstDuration, secondDuration, metadataCount)
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ package postgres
|
|||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
|
|
@ -54,7 +55,6 @@ func TestSendPrivateRichMessageSurvivesReadPaths(t *testing.T) {
|
|||
SenderUserID: sender.ID,
|
||||
RecipientUserID: recipient.ID,
|
||||
RandomID: time.Now().UnixNano(),
|
||||
Message: "rich",
|
||||
RichMessage: rich,
|
||||
Date: int(time.Now().Unix()),
|
||||
})
|
||||
|
|
@ -121,4 +121,16 @@ func TestSendPrivateRichMessageSurvivesReadPaths(t *testing.T) {
|
|||
if !sawEvent {
|
||||
t.Fatal("no new_message event for recipient")
|
||||
}
|
||||
|
||||
// The shared store invariant still rejects a command with no text, media, or
|
||||
// rich payload; accepting rich-only must not make truly empty rows possible.
|
||||
_, err = messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
|
||||
SenderUserID: sender.ID,
|
||||
RecipientUserID: recipient.ID,
|
||||
RandomID: time.Now().UnixNano(),
|
||||
Date: int(time.Now().Unix()),
|
||||
})
|
||||
if !errors.Is(err, domain.ErrMessageEmpty) {
|
||||
t.Fatalf("send empty private message err = %v, want ErrMessageEmpty", err)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -76,8 +76,17 @@ ON CONFLICT (id) DO NOTHING
|
|||
}
|
||||
|
||||
func (s *MessageStore) SendPrivateText(ctx context.Context, req domain.SendPrivateTextRequest) (res domain.SendPrivateTextResult, err error) {
|
||||
return s.sendPrivateTextWithHooks(ctx, req, privateSendTxHooks{})
|
||||
}
|
||||
|
||||
type privateSendTxHooks struct {
|
||||
before func(context.Context, pgx.Tx, *domain.SendPrivateTextRequest) error
|
||||
after func(context.Context, pgx.Tx, domain.SendPrivateTextResult) error
|
||||
}
|
||||
|
||||
func (s *MessageStore) sendPrivateTextWithHooks(ctx context.Context, req domain.SendPrivateTextRequest, hooks privateSendTxHooks) (res domain.SendPrivateTextResult, err error) {
|
||||
for attempt := 0; attempt < 2; attempt++ {
|
||||
res, err = s.sendPrivateTextOnce(ctx, req)
|
||||
res, err = s.sendPrivateTextOnce(ctx, req, hooks)
|
||||
if err == nil {
|
||||
return res, nil
|
||||
}
|
||||
|
|
@ -91,15 +100,15 @@ func (s *MessageStore) SendPrivateText(ctx context.Context, req domain.SendPriva
|
|||
return domain.SendPrivateTextResult{}, err
|
||||
}
|
||||
|
||||
func (s *MessageStore) sendPrivateTextOnce(ctx context.Context, req domain.SendPrivateTextRequest) (res domain.SendPrivateTextResult, err error) {
|
||||
func (s *MessageStore) sendPrivateTextOnce(ctx context.Context, req domain.SendPrivateTextRequest, hooks privateSendTxHooks) (res domain.SendPrivateTextResult, err error) {
|
||||
if req.SenderUserID == 0 || req.RecipientUserID == 0 {
|
||||
return domain.SendPrivateTextResult{}, fmt.Errorf("send private text: missing user id")
|
||||
}
|
||||
if req.RandomID == 0 {
|
||||
return domain.SendPrivateTextResult{}, fmt.Errorf("send private text: missing random id")
|
||||
}
|
||||
if req.Message == "" && req.Media.IsZero() {
|
||||
return domain.SendPrivateTextResult{}, fmt.Errorf("send private text: empty message")
|
||||
if !req.HasContent() {
|
||||
return domain.SendPrivateTextResult{}, domain.ErrMessageEmpty
|
||||
}
|
||||
if req.Date == 0 {
|
||||
req.Date = int(time.Now().Unix())
|
||||
|
|
@ -108,10 +117,6 @@ func (s *MessageStore) sendPrivateTextOnce(ctx context.Context, req domain.SendP
|
|||
if err != nil {
|
||||
return domain.SendPrivateTextResult{}, err
|
||||
}
|
||||
mediaJSON, err := encodeMessageMedia(req.Media)
|
||||
if err != nil {
|
||||
return domain.SendPrivateTextResult{}, err
|
||||
}
|
||||
// reply_markup(bot inline keyboard)随消息一并入双盒;普通用户发送恒 nil → "{}"。
|
||||
replyMarkupJSON, err := encodeReplyMarkup(req.ReplyMarkup)
|
||||
if err != nil {
|
||||
|
|
@ -181,6 +186,15 @@ func (s *MessageStore) sendPrivateTextOnce(ctx context.Context, req domain.SendP
|
|||
if err := lockUsersForUpdate(ctx, tx, req.SenderUserID, req.RecipientUserID); err != nil {
|
||||
return domain.SendPrivateTextResult{}, fmt.Errorf("lock send users: %w", err)
|
||||
}
|
||||
if hooks.before != nil {
|
||||
if err := hooks.before(ctx, tx, &req); err != nil {
|
||||
return domain.SendPrivateTextResult{}, err
|
||||
}
|
||||
}
|
||||
mediaJSON, err := encodeMessageMedia(req.Media)
|
||||
if err != nil {
|
||||
return domain.SendPrivateTextResult{}, err
|
||||
}
|
||||
ttlPeriod := req.TTLPeriod
|
||||
if ttlPeriod == 0 {
|
||||
ttlPeriod, err = privateHistoryTTLPeriod(ctx, tx, req.SenderUserID, req.RecipientUserID)
|
||||
|
|
@ -298,12 +312,21 @@ func (s *MessageStore) sendPrivateTextOnce(ctx context.Context, req domain.SendP
|
|||
if err := appendNewMessageEvent(ctx, qtx, sender); err != nil {
|
||||
return domain.SendPrivateTextResult{}, err
|
||||
}
|
||||
originUserID := req.OriginUserID
|
||||
if originUserID == 0 {
|
||||
originUserID = req.SenderUserID
|
||||
}
|
||||
senderExcludeAuthKeyID, senderExcludeSessionID := int64(0), int64(0)
|
||||
if originUserID == req.SenderUserID {
|
||||
senderExcludeAuthKeyID = authKeyIDToInt64(req.OriginAuthKeyID)
|
||||
senderExcludeSessionID = req.OriginSessionID
|
||||
}
|
||||
if err := enqueueDispatch(ctx, qtx, sqlcgen.EnqueueDispatchParams{
|
||||
TargetUserID: req.SenderUserID,
|
||||
Pts: int32(senderPts),
|
||||
EventType: string(domain.UpdateEventNewMessage),
|
||||
ExcludeAuthKeyID: authKeyIDToInt64(req.OriginAuthKeyID),
|
||||
ExcludeSessionID: req.OriginSessionID,
|
||||
ExcludeAuthKeyID: senderExcludeAuthKeyID,
|
||||
ExcludeSessionID: senderExcludeSessionID,
|
||||
}); err != nil {
|
||||
return domain.SendPrivateTextResult{}, fmt.Errorf("enqueue sender dispatch: %w", err)
|
||||
}
|
||||
|
|
@ -360,12 +383,17 @@ func (s *MessageStore) sendPrivateTextOnce(ctx context.Context, req domain.SendP
|
|||
if err := appendNewMessageEvent(ctx, qtx, recipient); err != nil {
|
||||
return domain.SendPrivateTextResult{}, err
|
||||
}
|
||||
recipientExcludeAuthKeyID, recipientExcludeSessionID := int64(0), int64(0)
|
||||
if originUserID == req.RecipientUserID {
|
||||
recipientExcludeAuthKeyID = authKeyIDToInt64(req.OriginAuthKeyID)
|
||||
recipientExcludeSessionID = req.OriginSessionID
|
||||
}
|
||||
if err := enqueueDispatch(ctx, qtx, sqlcgen.EnqueueDispatchParams{
|
||||
TargetUserID: req.RecipientUserID,
|
||||
Pts: int32(recipientPts),
|
||||
EventType: string(domain.UpdateEventNewMessage),
|
||||
ExcludeAuthKeyID: 0,
|
||||
ExcludeSessionID: 0,
|
||||
ExcludeAuthKeyID: recipientExcludeAuthKeyID,
|
||||
ExcludeSessionID: recipientExcludeSessionID,
|
||||
}); err != nil {
|
||||
return domain.SendPrivateTextResult{}, fmt.Errorf("enqueue recipient dispatch: %w", err)
|
||||
}
|
||||
|
|
@ -397,17 +425,23 @@ WHERE sender_user_id = $1
|
|||
if tag.RowsAffected() != 1 {
|
||||
return domain.SendPrivateTextResult{}, fmt.Errorf("save private send receipt: private message %d already has or lost its immutable receipt", pm.ID)
|
||||
}
|
||||
result := domain.SendPrivateTextResult{
|
||||
SenderMessage: sender,
|
||||
RecipientMessage: recipient,
|
||||
SenderEvent: eventFromMessage(sender),
|
||||
RecipientEvent: eventFromMessage(recipient),
|
||||
}
|
||||
if hooks.after != nil {
|
||||
if err := hooks.after(ctx, tx, result); err != nil {
|
||||
return domain.SendPrivateTextResult{}, err
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return domain.SendPrivateTextResult{}, fmt.Errorf("commit send message tx: %w", err)
|
||||
}
|
||||
committed = true
|
||||
return domain.SendPrivateTextResult{
|
||||
SenderMessage: sender,
|
||||
RecipientMessage: recipient,
|
||||
SenderEvent: eventFromMessage(sender),
|
||||
RecipientEvent: eventFromMessage(recipient),
|
||||
}, nil
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// LookupPrivateSendReplay reads an existing receipt without permission checks, source/media
|
||||
|
|
|
|||
|
|
@ -1,10 +0,0 @@
|
|||
-- name: GetAuthKey :one
|
||||
SELECT auth_key_id, body, server_salt, created_at
|
||||
FROM auth_keys
|
||||
WHERE auth_key_id = $1;
|
||||
|
||||
-- name: UpsertAuthKey :exec
|
||||
INSERT INTO auth_keys (auth_key_id, body, server_salt)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (auth_key_id) DO UPDATE
|
||||
SET body = EXCLUDED.body, server_salt = EXCLUDED.server_salt;
|
||||
|
|
@ -30,6 +30,36 @@ ON CONFLICT (lang_pack, lang_code, key) DO UPDATE SET
|
|||
deleted = EXCLUDED.deleted,
|
||||
updated_at = now();
|
||||
|
||||
-- name: ListLangPackCodes :many
|
||||
SELECT lang_code
|
||||
FROM lang_packs
|
||||
WHERE lang_pack = $1
|
||||
ORDER BY lang_code;
|
||||
|
||||
-- name: DeleteLangPackStrings :exec
|
||||
DELETE FROM lang_pack_strings
|
||||
WHERE lang_pack = $1 AND lang_code = $2;
|
||||
|
||||
-- name: DeleteLangPackMeta :exec
|
||||
DELETE FROM lang_packs
|
||||
WHERE lang_pack = $1 AND lang_code = $2;
|
||||
|
||||
-- name: GetLangPackSeedHash :one
|
||||
SELECT content_hash
|
||||
FROM seed_states
|
||||
WHERE key = $1;
|
||||
|
||||
-- name: PutLangPackSeedHash :exec
|
||||
INSERT INTO seed_states (key, content_hash)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT (key) DO UPDATE SET
|
||||
content_hash = EXCLUDED.content_hash,
|
||||
updated_at = now();
|
||||
|
||||
-- name: DeleteLangPackSeedHash :exec
|
||||
DELETE FROM seed_states
|
||||
WHERE key = $1;
|
||||
|
||||
-- name: ListLangPackStrings :many
|
||||
SELECT
|
||||
lang_pack, lang_code, key, version, pluralized, value,
|
||||
|
|
@ -45,3 +75,48 @@ SELECT
|
|||
FROM lang_pack_strings
|
||||
WHERE lang_pack = $1 AND lang_code = $2 AND key = ANY(sqlc.arg(keys)::text[]) AND NOT deleted
|
||||
ORDER BY key;
|
||||
|
||||
-- name: ListLangPackLanguages :many
|
||||
SELECT
|
||||
p.lang_pack,
|
||||
p.lang_code,
|
||||
p.version,
|
||||
p.strings_count,
|
||||
COALESCE(
|
||||
MAX(s.value) FILTER (WHERE s.key = 'LanguageNameInEnglish'),
|
||||
MAX(s.value) FILTER (WHERE s.key = 'Localization.EnglishLanguageName'),
|
||||
''
|
||||
)::text AS name,
|
||||
CASE
|
||||
WHEN p.lang_pack = 'android' THEN COALESCE(
|
||||
MAX(s.value) FILTER (WHERE s.key = 'TranslateLanguage' || upper(split_part(replace(p.lang_code, '-', '_'), '_', 1))),
|
||||
MAX(s.value) FILTER (WHERE s.key = 'PassportLanguage_' || upper(split_part(replace(p.lang_code, '-', '_'), '_', 1))),
|
||||
MAX(s.value) FILTER (WHERE s.key = 'LanguageName'),
|
||||
''
|
||||
)
|
||||
ELSE COALESCE(
|
||||
MAX(s.value) FILTER (WHERE s.key = 'lng_language_name'),
|
||||
MAX(s.value) FILTER (WHERE s.key = 'LanguageName'),
|
||||
MAX(s.value) FILTER (WHERE s.key = 'Localization.LanguageName'),
|
||||
MAX(s.value) FILTER (WHERE s.key = 'TranslateLanguage' || upper(split_part(replace(p.lang_code, '-', '_'), '_', 1))),
|
||||
MAX(s.value) FILTER (WHERE s.key = 'PassportLanguage_' || upper(split_part(replace(p.lang_code, '-', '_'), '_', 1))),
|
||||
''
|
||||
)
|
||||
END::text AS native_name
|
||||
FROM lang_packs p
|
||||
LEFT JOIN lang_pack_strings s
|
||||
ON s.lang_pack = p.lang_pack
|
||||
AND s.lang_code = p.lang_code
|
||||
AND s.key = ANY(ARRAY[
|
||||
'lng_language_name',
|
||||
'LanguageName',
|
||||
'LanguageNameInEnglish',
|
||||
'Localization.LanguageName',
|
||||
'Localization.EnglishLanguageName',
|
||||
'TranslateLanguage' || upper(split_part(replace(p.lang_code, '-', '_'), '_', 1)),
|
||||
'PassportLanguage_' || upper(split_part(replace(p.lang_code, '-', '_'), '_', 1))
|
||||
]::text[])
|
||||
AND NOT s.deleted
|
||||
WHERE p.lang_pack = $1
|
||||
GROUP BY p.lang_pack, p.lang_code, p.version, p.strings_count
|
||||
ORDER BY p.lang_code;
|
||||
|
|
|
|||
|
|
@ -1,15 +1,21 @@
|
|||
-- name: UpsertTempAuthKeyBinding :exec
|
||||
-- name: UpsertTempAuthKeyBinding :execrows
|
||||
INSERT INTO temp_auth_key_bindings (
|
||||
temp_auth_key_id, perm_auth_key_id, nonce, temp_session_id, expires_at, encrypted_message
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
SELECT $1, $2, $3, $4, $5, $6
|
||||
FROM auth_keys AS temp_key
|
||||
JOIN auth_keys AS perm_key ON perm_key.auth_key_id = $2
|
||||
WHERE temp_key.auth_key_id = $1
|
||||
AND temp_key.expires_at = $5
|
||||
AND temp_key.expires_at > 0
|
||||
AND perm_key.expires_at = 0
|
||||
ON CONFLICT (temp_auth_key_id) DO UPDATE SET
|
||||
perm_auth_key_id = EXCLUDED.perm_auth_key_id,
|
||||
nonce = EXCLUDED.nonce,
|
||||
temp_session_id = EXCLUDED.temp_session_id,
|
||||
expires_at = EXCLUDED.expires_at,
|
||||
encrypted_message = EXCLUDED.encrypted_message,
|
||||
created_at = now();
|
||||
created_at = now()
|
||||
WHERE temp_auth_key_bindings.perm_auth_key_id = EXCLUDED.perm_auth_key_id;
|
||||
|
||||
-- name: GetTempAuthKeyBinding :one
|
||||
SELECT
|
||||
|
|
@ -23,10 +29,15 @@ FROM temp_auth_key_bindings
|
|||
WHERE temp_auth_key_id = $1;
|
||||
|
||||
-- name: DeleteExpiredTempAuthKeys :execrows
|
||||
DELETE FROM auth_keys
|
||||
WHERE auth_key_id IN (
|
||||
SELECT temp_auth_key_id
|
||||
FROM temp_auth_key_bindings
|
||||
WHERE expires_at < $1
|
||||
WITH candidates AS (
|
||||
SELECT candidate_key.auth_key_id
|
||||
FROM auth_keys AS candidate_key
|
||||
WHERE candidate_key.expires_at > 0
|
||||
AND candidate_key.expires_at < $1
|
||||
ORDER BY candidate_key.expires_at, candidate_key.auth_key_id
|
||||
LIMIT $2
|
||||
);
|
||||
FOR UPDATE SKIP LOCKED
|
||||
)
|
||||
DELETE FROM auth_keys AS k
|
||||
USING candidates AS c
|
||||
WHERE k.auth_key_id = c.auth_key_id;
|
||||
|
|
|
|||
|
|
@ -33,6 +33,12 @@ type ReadModelCacheSet struct {
|
|||
RPCProjections RPCProjectionReadModelCache
|
||||
BaseUsers BaseUserCache
|
||||
BotProfiles BotProfileReadModelCache
|
||||
StarGifts StarGiftCatalogCache
|
||||
}
|
||||
|
||||
type StarGiftCatalogCache interface {
|
||||
InvalidateStarGiftCatalog()
|
||||
FlushStarGiftCatalog()
|
||||
}
|
||||
|
||||
// BaseUserCache 是跨进程共享的 user:base 缓存(Redis)。user_base read-model 事件必须删除
|
||||
|
|
@ -214,7 +220,8 @@ func (l *ReadModelChangeListener) empty() bool {
|
|||
l.caches.PrivateMediaCounts == nil &&
|
||||
l.caches.RPCProjections == nil &&
|
||||
l.caches.BaseUsers == nil &&
|
||||
l.caches.BotProfiles == nil
|
||||
l.caches.BotProfiles == nil &&
|
||||
l.caches.StarGifts == nil
|
||||
}
|
||||
|
||||
func (l *ReadModelChangeListener) flush(reasons ...string) {
|
||||
|
|
@ -287,6 +294,10 @@ func (l *ReadModelChangeListener) flush(reasons ...string) {
|
|||
l.caches.BotProfiles.FlushBotProfileReadModel()
|
||||
flushed = append(flushed, "bot_profiles")
|
||||
}
|
||||
if l.caches.StarGifts != nil {
|
||||
l.caches.StarGifts.FlushStarGiftCatalog()
|
||||
flushed = append(flushed, "star_gifts")
|
||||
}
|
||||
// 注意:BaseUsers(Redis) 刻意不在重连时 flush——它是跨实例共享缓存,整库清空会误伤
|
||||
// 其它实例;漏掉的通知由其 5min TTL 兜底。
|
||||
l.log.Info("read model caches flushed",
|
||||
|
|
@ -315,6 +326,10 @@ func (l *ReadModelChangeListener) handlePayload(payload string) {
|
|||
}
|
||||
}
|
||||
switch evt.Model {
|
||||
case "star_gift_catalog":
|
||||
if l.caches.StarGifts != nil {
|
||||
l.caches.StarGifts.InvalidateStarGiftCatalog()
|
||||
}
|
||||
case "user_base":
|
||||
if evt.PeerType == "user" && evt.PeerID != 0 {
|
||||
if l.caches.RPCProjections != nil {
|
||||
|
|
|
|||
|
|
@ -1,55 +0,0 @@
|
|||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// source: authkey.sql
|
||||
|
||||
package sqlcgen
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
const getAuthKey = `-- name: GetAuthKey :one
|
||||
SELECT auth_key_id, body, server_salt, created_at
|
||||
FROM auth_keys
|
||||
WHERE auth_key_id = $1
|
||||
`
|
||||
|
||||
type GetAuthKeyRow struct {
|
||||
AuthKeyID int64
|
||||
Body []byte
|
||||
ServerSalt int64
|
||||
CreatedAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
func (q *Queries) GetAuthKey(ctx context.Context, authKeyID int64) (GetAuthKeyRow, error) {
|
||||
row := q.db.QueryRow(ctx, getAuthKey, authKeyID)
|
||||
var i GetAuthKeyRow
|
||||
err := row.Scan(
|
||||
&i.AuthKeyID,
|
||||
&i.Body,
|
||||
&i.ServerSalt,
|
||||
&i.CreatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const upsertAuthKey = `-- name: UpsertAuthKey :exec
|
||||
INSERT INTO auth_keys (auth_key_id, body, server_salt)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (auth_key_id) DO UPDATE
|
||||
SET body = EXCLUDED.body, server_salt = EXCLUDED.server_salt
|
||||
`
|
||||
|
||||
type UpsertAuthKeyParams struct {
|
||||
AuthKeyID int64
|
||||
Body []byte
|
||||
ServerSalt int64
|
||||
}
|
||||
|
||||
func (q *Queries) UpsertAuthKey(ctx context.Context, arg UpsertAuthKeyParams) error {
|
||||
_, err := q.db.Exec(ctx, upsertAuthKey, arg.AuthKeyID, arg.Body, arg.ServerSalt)
|
||||
return err
|
||||
}
|
||||
|
|
@ -9,6 +9,46 @@ import (
|
|||
"context"
|
||||
)
|
||||
|
||||
const deleteLangPackMeta = `-- name: DeleteLangPackMeta :exec
|
||||
DELETE FROM lang_packs
|
||||
WHERE lang_pack = $1 AND lang_code = $2
|
||||
`
|
||||
|
||||
type DeleteLangPackMetaParams struct {
|
||||
LangPack string
|
||||
LangCode string
|
||||
}
|
||||
|
||||
func (q *Queries) DeleteLangPackMeta(ctx context.Context, arg DeleteLangPackMetaParams) error {
|
||||
_, err := q.db.Exec(ctx, deleteLangPackMeta, arg.LangPack, arg.LangCode)
|
||||
return err
|
||||
}
|
||||
|
||||
const deleteLangPackSeedHash = `-- name: DeleteLangPackSeedHash :exec
|
||||
DELETE FROM seed_states
|
||||
WHERE key = $1
|
||||
`
|
||||
|
||||
func (q *Queries) DeleteLangPackSeedHash(ctx context.Context, key string) error {
|
||||
_, err := q.db.Exec(ctx, deleteLangPackSeedHash, key)
|
||||
return err
|
||||
}
|
||||
|
||||
const deleteLangPackStrings = `-- name: DeleteLangPackStrings :exec
|
||||
DELETE FROM lang_pack_strings
|
||||
WHERE lang_pack = $1 AND lang_code = $2
|
||||
`
|
||||
|
||||
type DeleteLangPackStringsParams struct {
|
||||
LangPack string
|
||||
LangCode string
|
||||
}
|
||||
|
||||
func (q *Queries) DeleteLangPackStrings(ctx context.Context, arg DeleteLangPackStringsParams) error {
|
||||
_, err := q.db.Exec(ctx, deleteLangPackStrings, arg.LangPack, arg.LangCode)
|
||||
return err
|
||||
}
|
||||
|
||||
const getLangPackMeta = `-- name: GetLangPackMeta :one
|
||||
SELECT lang_pack, lang_code, version, strings_count
|
||||
FROM lang_packs
|
||||
|
|
@ -39,6 +79,19 @@ func (q *Queries) GetLangPackMeta(ctx context.Context, arg GetLangPackMetaParams
|
|||
return i, err
|
||||
}
|
||||
|
||||
const getLangPackSeedHash = `-- name: GetLangPackSeedHash :one
|
||||
SELECT content_hash
|
||||
FROM seed_states
|
||||
WHERE key = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetLangPackSeedHash(ctx context.Context, key string) (string, error) {
|
||||
row := q.db.QueryRow(ctx, getLangPackSeedHash, key)
|
||||
var content_hash string
|
||||
err := row.Scan(&content_hash)
|
||||
return content_hash, err
|
||||
}
|
||||
|
||||
const getLangPackStringsByKeys = `-- name: GetLangPackStringsByKeys :many
|
||||
SELECT
|
||||
lang_pack, lang_code, key, version, pluralized, value,
|
||||
|
|
@ -104,6 +157,115 @@ func (q *Queries) GetLangPackStringsByKeys(ctx context.Context, arg GetLangPackS
|
|||
return items, nil
|
||||
}
|
||||
|
||||
const listLangPackCodes = `-- name: ListLangPackCodes :many
|
||||
SELECT lang_code
|
||||
FROM lang_packs
|
||||
WHERE lang_pack = $1
|
||||
ORDER BY lang_code
|
||||
`
|
||||
|
||||
func (q *Queries) ListLangPackCodes(ctx context.Context, langPack string) ([]string, error) {
|
||||
rows, err := q.db.Query(ctx, listLangPackCodes, langPack)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []string
|
||||
for rows.Next() {
|
||||
var lang_code string
|
||||
if err := rows.Scan(&lang_code); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, lang_code)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listLangPackLanguages = `-- name: ListLangPackLanguages :many
|
||||
SELECT
|
||||
p.lang_pack,
|
||||
p.lang_code,
|
||||
p.version,
|
||||
p.strings_count,
|
||||
COALESCE(
|
||||
MAX(s.value) FILTER (WHERE s.key = 'LanguageNameInEnglish'),
|
||||
MAX(s.value) FILTER (WHERE s.key = 'Localization.EnglishLanguageName'),
|
||||
''
|
||||
)::text AS name,
|
||||
CASE
|
||||
WHEN p.lang_pack = 'android' THEN COALESCE(
|
||||
MAX(s.value) FILTER (WHERE s.key = 'TranslateLanguage' || upper(split_part(replace(p.lang_code, '-', '_'), '_', 1))),
|
||||
MAX(s.value) FILTER (WHERE s.key = 'PassportLanguage_' || upper(split_part(replace(p.lang_code, '-', '_'), '_', 1))),
|
||||
MAX(s.value) FILTER (WHERE s.key = 'LanguageName'),
|
||||
''
|
||||
)
|
||||
ELSE COALESCE(
|
||||
MAX(s.value) FILTER (WHERE s.key = 'lng_language_name'),
|
||||
MAX(s.value) FILTER (WHERE s.key = 'LanguageName'),
|
||||
MAX(s.value) FILTER (WHERE s.key = 'Localization.LanguageName'),
|
||||
MAX(s.value) FILTER (WHERE s.key = 'TranslateLanguage' || upper(split_part(replace(p.lang_code, '-', '_'), '_', 1))),
|
||||
MAX(s.value) FILTER (WHERE s.key = 'PassportLanguage_' || upper(split_part(replace(p.lang_code, '-', '_'), '_', 1))),
|
||||
''
|
||||
)
|
||||
END::text AS native_name
|
||||
FROM lang_packs p
|
||||
LEFT JOIN lang_pack_strings s
|
||||
ON s.lang_pack = p.lang_pack
|
||||
AND s.lang_code = p.lang_code
|
||||
AND s.key = ANY(ARRAY[
|
||||
'lng_language_name',
|
||||
'LanguageName',
|
||||
'LanguageNameInEnglish',
|
||||
'Localization.LanguageName',
|
||||
'Localization.EnglishLanguageName',
|
||||
'TranslateLanguage' || upper(split_part(replace(p.lang_code, '-', '_'), '_', 1)),
|
||||
'PassportLanguage_' || upper(split_part(replace(p.lang_code, '-', '_'), '_', 1))
|
||||
]::text[])
|
||||
AND NOT s.deleted
|
||||
WHERE p.lang_pack = $1
|
||||
GROUP BY p.lang_pack, p.lang_code, p.version, p.strings_count
|
||||
ORDER BY p.lang_code
|
||||
`
|
||||
|
||||
type ListLangPackLanguagesRow struct {
|
||||
LangPack string
|
||||
LangCode string
|
||||
Version int32
|
||||
StringsCount int32
|
||||
Name string
|
||||
NativeName string
|
||||
}
|
||||
|
||||
func (q *Queries) ListLangPackLanguages(ctx context.Context, langPack string) ([]ListLangPackLanguagesRow, error) {
|
||||
rows, err := q.db.Query(ctx, listLangPackLanguages, langPack)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ListLangPackLanguagesRow
|
||||
for rows.Next() {
|
||||
var i ListLangPackLanguagesRow
|
||||
if err := rows.Scan(
|
||||
&i.LangPack,
|
||||
&i.LangCode,
|
||||
&i.Version,
|
||||
&i.StringsCount,
|
||||
&i.Name,
|
||||
&i.NativeName,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listLangPackStrings = `-- name: ListLangPackStrings :many
|
||||
SELECT
|
||||
lang_pack, lang_code, key, version, pluralized, value,
|
||||
|
|
@ -168,6 +330,24 @@ func (q *Queries) ListLangPackStrings(ctx context.Context, arg ListLangPackStrin
|
|||
return items, nil
|
||||
}
|
||||
|
||||
const putLangPackSeedHash = `-- name: PutLangPackSeedHash :exec
|
||||
INSERT INTO seed_states (key, content_hash)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT (key) DO UPDATE SET
|
||||
content_hash = EXCLUDED.content_hash,
|
||||
updated_at = now()
|
||||
`
|
||||
|
||||
type PutLangPackSeedHashParams struct {
|
||||
Key string
|
||||
ContentHash string
|
||||
}
|
||||
|
||||
func (q *Queries) PutLangPackSeedHash(ctx context.Context, arg PutLangPackSeedHashParams) error {
|
||||
_, err := q.db.Exec(ctx, putLangPackSeedHash, arg.Key, arg.ContentHash)
|
||||
return err
|
||||
}
|
||||
|
||||
const upsertLangPackMeta = `-- name: UpsertLangPackMeta :exec
|
||||
INSERT INTO lang_packs (lang_pack, lang_code, version, strings_count)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
|
|
|
|||
|
|
@ -53,13 +53,16 @@ type AccountReactionSetting struct {
|
|||
UpdatedAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
type AccountSendRestriction struct {
|
||||
UserID int64
|
||||
Frozen bool
|
||||
Reason string
|
||||
Actor string
|
||||
CommandID string
|
||||
UpdatedAt pgtype.Timestamptz
|
||||
type AccountRestriction struct {
|
||||
UserID int64
|
||||
Frozen bool
|
||||
Reason string
|
||||
Actor string
|
||||
CommandID string
|
||||
UpdatedAt pgtype.Timestamptz
|
||||
FrozenSince pgtype.Timestamptz
|
||||
FrozenUntil pgtype.Timestamptz
|
||||
AppealUrl string
|
||||
}
|
||||
|
||||
type AccountSetting struct {
|
||||
|
|
@ -131,6 +134,17 @@ type AiComposeToneSafe struct {
|
|||
SavedAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
// Durable pre-send binding from album item random_id to grouped_id; never reconstructed from a retry subset.
|
||||
type AlbumGroupReservation struct {
|
||||
SenderUserID int64
|
||||
PeerType string
|
||||
PeerID int64
|
||||
RandomID int64
|
||||
IntentHash []byte
|
||||
GroupedID int64
|
||||
CreatedAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
type AppConfig struct {
|
||||
Client string
|
||||
Hash int32
|
||||
|
|
@ -164,17 +178,28 @@ type AttachMenuUserState struct {
|
|||
}
|
||||
|
||||
type AuthKey struct {
|
||||
AuthKeyID int64
|
||||
Body []byte
|
||||
ServerSalt int64
|
||||
CreatedAt pgtype.Timestamptz
|
||||
AuthKeyID int64
|
||||
Body []byte
|
||||
ServerSalt int64
|
||||
CreatedAt pgtype.Timestamptz
|
||||
Layer int32
|
||||
DeviceModel string
|
||||
Platform string
|
||||
SystemVersion string
|
||||
ApiID int32
|
||||
AppVersion string
|
||||
LastUsedAt pgtype.Timestamptz
|
||||
ExpiresAt int32
|
||||
LayerObservationID int64
|
||||
}
|
||||
|
||||
type AuthKeySessionLayer struct {
|
||||
RawAuthKeyID int64
|
||||
SessionID int64
|
||||
Layer int32
|
||||
DeviceModel string
|
||||
Platform string
|
||||
SystemVersion string
|
||||
ApiID int32
|
||||
AppVersion string
|
||||
LastUsedAt pgtype.Timestamptz
|
||||
MsgID int64
|
||||
ObservationID int64
|
||||
ExpiresAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
type Authorization struct {
|
||||
|
|
@ -627,6 +652,7 @@ type ChannelMessage struct {
|
|||
DeletePtsCount int32
|
||||
DeleteDate int32
|
||||
DeleteMessageIds []byte
|
||||
RequestFingerprint []byte
|
||||
}
|
||||
|
||||
type ChannelMessageMedium struct {
|
||||
|
|
@ -1032,6 +1058,20 @@ type LangPackString struct {
|
|||
UpdatedAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
type LoginCodeMessageDelivery struct {
|
||||
// SHA-256(phone_code_hash); raw phone_code_hash is never persisted
|
||||
DeliveryKey []byte
|
||||
// HMAC-SHA-256(code), keyed by the non-persisted raw phone_code_hash
|
||||
CodeFingerprint []byte
|
||||
UserID int64
|
||||
PrivateMessageID int64
|
||||
MessageBoxID int32
|
||||
Pts int32
|
||||
MessageDate int32
|
||||
CreatedAt pgtype.Timestamptz
|
||||
ExpiresAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
type MessageBox struct {
|
||||
OwnerUserID int64
|
||||
BoxID int32
|
||||
|
|
@ -1131,6 +1171,14 @@ type PeerStarGift struct {
|
|||
SavedID int64
|
||||
}
|
||||
|
||||
type PeerTranslationSetting struct {
|
||||
UserID int64
|
||||
PeerType string
|
||||
PeerID int64
|
||||
Disabled bool
|
||||
UpdatedAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
type PeerUsername struct {
|
||||
UsernameLower string
|
||||
PeerType string
|
||||
|
|
@ -1631,6 +1679,7 @@ type UserStickerCollection struct {
|
|||
Kind string
|
||||
DocumentID int64
|
||||
UsedAt int32
|
||||
OrderKey int64
|
||||
}
|
||||
|
||||
type UserStickerSet struct {
|
||||
|
|
|
|||
|
|
@ -10,13 +10,18 @@ import (
|
|||
)
|
||||
|
||||
const deleteExpiredTempAuthKeys = `-- name: DeleteExpiredTempAuthKeys :execrows
|
||||
DELETE FROM auth_keys
|
||||
WHERE auth_key_id IN (
|
||||
SELECT temp_auth_key_id
|
||||
FROM temp_auth_key_bindings
|
||||
WHERE expires_at < $1
|
||||
WITH candidates AS (
|
||||
SELECT candidate_key.auth_key_id
|
||||
FROM auth_keys AS candidate_key
|
||||
WHERE candidate_key.expires_at > 0
|
||||
AND candidate_key.expires_at < $1
|
||||
ORDER BY candidate_key.expires_at, candidate_key.auth_key_id
|
||||
LIMIT $2
|
||||
FOR UPDATE SKIP LOCKED
|
||||
)
|
||||
DELETE FROM auth_keys AS k
|
||||
USING candidates AS c
|
||||
WHERE k.auth_key_id = c.auth_key_id
|
||||
`
|
||||
|
||||
type DeleteExpiredTempAuthKeysParams struct {
|
||||
|
|
@ -67,18 +72,24 @@ func (q *Queries) GetTempAuthKeyBinding(ctx context.Context, tempAuthKeyID int64
|
|||
return i, err
|
||||
}
|
||||
|
||||
const upsertTempAuthKeyBinding = `-- name: UpsertTempAuthKeyBinding :exec
|
||||
const upsertTempAuthKeyBinding = `-- name: UpsertTempAuthKeyBinding :execrows
|
||||
INSERT INTO temp_auth_key_bindings (
|
||||
temp_auth_key_id, perm_auth_key_id, nonce, temp_session_id, expires_at, encrypted_message
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
SELECT $1, $2, $3, $4, $5, $6
|
||||
FROM auth_keys AS temp_key
|
||||
JOIN auth_keys AS perm_key ON perm_key.auth_key_id = $2
|
||||
WHERE temp_key.auth_key_id = $1
|
||||
AND temp_key.expires_at = $5
|
||||
AND temp_key.expires_at > 0
|
||||
AND perm_key.expires_at = 0
|
||||
ON CONFLICT (temp_auth_key_id) DO UPDATE SET
|
||||
perm_auth_key_id = EXCLUDED.perm_auth_key_id,
|
||||
nonce = EXCLUDED.nonce,
|
||||
temp_session_id = EXCLUDED.temp_session_id,
|
||||
expires_at = EXCLUDED.expires_at,
|
||||
encrypted_message = EXCLUDED.encrypted_message,
|
||||
created_at = now()
|
||||
WHERE temp_auth_key_bindings.perm_auth_key_id = EXCLUDED.perm_auth_key_id
|
||||
`
|
||||
|
||||
type UpsertTempAuthKeyBindingParams struct {
|
||||
|
|
@ -90,8 +101,8 @@ type UpsertTempAuthKeyBindingParams struct {
|
|||
EncryptedMessage []byte
|
||||
}
|
||||
|
||||
func (q *Queries) UpsertTempAuthKeyBinding(ctx context.Context, arg UpsertTempAuthKeyBindingParams) error {
|
||||
_, err := q.db.Exec(ctx, upsertTempAuthKeyBinding,
|
||||
func (q *Queries) UpsertTempAuthKeyBinding(ctx context.Context, arg UpsertTempAuthKeyBindingParams) (int64, error) {
|
||||
result, err := q.db.Exec(ctx, upsertTempAuthKeyBinding,
|
||||
arg.TempAuthKeyID,
|
||||
arg.PermAuthKeyID,
|
||||
arg.Nonce,
|
||||
|
|
@ -99,5 +110,8 @@ func (q *Queries) UpsertTempAuthKeyBinding(ctx context.Context, arg UpsertTempAu
|
|||
arg.ExpiresAt,
|
||||
arg.EncryptedMessage,
|
||||
)
|
||||
return err
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected(), nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import (
|
|||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
|
|
@ -21,6 +22,290 @@ func NewStarGiftStore(db sqlcgen.DBTX) *StarGiftStore {
|
|||
return &StarGiftStore{db: db}
|
||||
}
|
||||
|
||||
const starGiftCatalogSelect = `
|
||||
SELECT c.gift_id, r.id, r.stars, r.convert_stars, r.title,
|
||||
COALESCE(cr.upgrade_stars, 0), COALESCE(cr.supply_total, 0), COALESCE(cr.issued, 0),
|
||||
d.id, d.access_hash, d.file_reference, d.date, d.mime_type, d.size, d.dc_id,
|
||||
d.attributes::text, d.thumbs::text
|
||||
FROM star_gift_catalog c
|
||||
JOIN star_gift_catalog_revisions r ON r.id = c.active_revision_id
|
||||
LEFT JOIN star_gift_collectible_revisions cr ON cr.id = c.collectible_revision_id AND cr.status = 'published'
|
||||
JOIN documents d ON d.id = r.document_id`
|
||||
|
||||
func (s *StarGiftStore) Catalog(ctx context.Context) ([]domain.StarGift, error) {
|
||||
rows, err := s.db.Query(ctx, starGiftCatalogSelect+`
|
||||
WHERE c.enabled
|
||||
ORDER BY c.sort_order, c.gift_id`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list star gift catalog: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]domain.StarGift, 0)
|
||||
for rows.Next() {
|
||||
gift, err := scanCatalogGift(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, gift)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate star gift catalog: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *StarGiftStore) CatalogGift(ctx context.Context, giftID int64) (domain.StarGift, bool, error) {
|
||||
if giftID <= 0 {
|
||||
return domain.StarGift{}, false, nil
|
||||
}
|
||||
gift, err := scanCatalogGift(s.db.QueryRow(ctx, starGiftCatalogSelect+`
|
||||
WHERE c.enabled AND c.gift_id = $1`, giftID))
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.StarGift{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return domain.StarGift{}, false, err
|
||||
}
|
||||
return gift, true, nil
|
||||
}
|
||||
|
||||
func (s *StarGiftStore) CatalogRevision(ctx context.Context, revisionID int64) (domain.StarGift, bool, error) {
|
||||
if revisionID <= 0 {
|
||||
return domain.StarGift{}, false, nil
|
||||
}
|
||||
gift, err := scanCatalogGift(s.db.QueryRow(ctx, `
|
||||
SELECT r.gift_id, r.id, r.stars, r.convert_stars, r.title,
|
||||
COALESCE(cr.upgrade_stars, 0), COALESCE(cr.supply_total, 0), COALESCE(cr.issued, 0),
|
||||
d.id, d.access_hash, d.file_reference, d.date, d.mime_type, d.size, d.dc_id,
|
||||
d.attributes::text, d.thumbs::text
|
||||
FROM star_gift_catalog_revisions r
|
||||
JOIN star_gift_catalog c ON c.gift_id = r.gift_id
|
||||
LEFT JOIN star_gift_collectible_revisions cr ON cr.id = c.collectible_revision_id AND cr.status = 'published'
|
||||
JOIN documents d ON d.id = r.document_id
|
||||
WHERE r.id = $1`, revisionID))
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.StarGift{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return domain.StarGift{}, false, err
|
||||
}
|
||||
return gift, true, nil
|
||||
}
|
||||
|
||||
func scanCatalogGift(row rowScanner) (domain.StarGift, error) {
|
||||
var gift domain.StarGift
|
||||
var attrsJSON, thumbsJSON string
|
||||
if err := row.Scan(
|
||||
&gift.ID, &gift.RevisionID, &gift.Stars, &gift.ConvertStars, &gift.Title,
|
||||
&gift.UpgradeStars, &gift.UpgradeTotal, &gift.UpgradeIssued,
|
||||
&gift.Sticker.ID, &gift.Sticker.AccessHash, &gift.Sticker.FileReference, &gift.Sticker.Date,
|
||||
&gift.Sticker.MimeType, &gift.Sticker.Size, &gift.Sticker.DCID, &attrsJSON, &thumbsJSON,
|
||||
); err != nil {
|
||||
return domain.StarGift{}, err
|
||||
}
|
||||
attrs, err := decodeDocumentAttributes(attrsJSON)
|
||||
if err != nil {
|
||||
return domain.StarGift{}, fmt.Errorf("decode star gift document attributes: %w", err)
|
||||
}
|
||||
thumbs, err := decodePhotoSizes(thumbsJSON)
|
||||
if err != nil {
|
||||
return domain.StarGift{}, fmt.Errorf("decode star gift document thumbs: %w", err)
|
||||
}
|
||||
gift.Sticker.Attributes = attrs
|
||||
gift.Sticker.Thumbs = thumbs
|
||||
if !gift.Sticker.IsSticker() || gift.Sticker.MimeType != "application/x-tgsticker" {
|
||||
return domain.StarGift{}, fmt.Errorf("invalid star gift revision %d document %d", gift.RevisionID, gift.Sticker.ID)
|
||||
}
|
||||
return gift, nil
|
||||
}
|
||||
|
||||
func (s *StarGiftStore) CreateCatalogRevision(ctx context.Context, write domain.StarGiftCatalogWrite) (domain.StarGiftCatalogEntry, error) {
|
||||
if write.Stars <= 0 || write.ConvertStars < 0 || write.ConvertStars > write.Stars ||
|
||||
write.Document.ID <= 0 || !write.Document.IsSticker() || write.Document.MimeType != "application/x-tgsticker" ||
|
||||
len(write.Animation.JSON) == 0 || len(write.Animation.SHA256) != 32 {
|
||||
return domain.StarGiftCatalogEntry{}, domain.ErrStarGiftInvalid
|
||||
}
|
||||
var entry domain.StarGiftCatalogEntry
|
||||
err := withTx(ctx, s.db, "create star gift catalog revision", func(tx pgx.Tx) error {
|
||||
giftID := write.GiftID
|
||||
var revisionID int64
|
||||
if err := tx.QueryRow(ctx, `SELECT nextval('star_gift_catalog_revision_id_seq')`).Scan(&revisionID); err != nil {
|
||||
return fmt.Errorf("allocate star gift revision id: %w", err)
|
||||
}
|
||||
revision := 1
|
||||
if giftID == 0 {
|
||||
if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended('star_gift_catalog', 0))`); err != nil {
|
||||
return fmt.Errorf("lock star gift catalog capacity: %w", err)
|
||||
}
|
||||
var catalogCount int
|
||||
if err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM star_gift_catalog`).Scan(&catalogCount); err != nil {
|
||||
return fmt.Errorf("count star gift catalog: %w", err)
|
||||
}
|
||||
if catalogCount >= domain.MaxStarGiftCatalogSize {
|
||||
return domain.ErrStarGiftCatalogFull
|
||||
}
|
||||
if err := tx.QueryRow(ctx, `SELECT nextval('star_gift_catalog_gift_id_seq')`).Scan(&giftID); err != nil {
|
||||
return fmt.Errorf("allocate star gift id: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO star_gift_catalog (gift_id, active_revision_id, enabled, sort_order)
|
||||
VALUES ($1,$2,$3,$4)`, giftID, revisionID, write.Enabled, write.SortOrder); err != nil {
|
||||
return fmt.Errorf("insert star gift catalog: %w", err)
|
||||
}
|
||||
} else {
|
||||
var ignored int64
|
||||
if err := tx.QueryRow(ctx, `
|
||||
SELECT active_revision_id FROM star_gift_catalog WHERE gift_id=$1 FOR UPDATE`, giftID).Scan(&ignored); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.ErrStarGiftNotFound
|
||||
}
|
||||
return fmt.Errorf("lock star gift catalog: %w", err)
|
||||
}
|
||||
if err := tx.QueryRow(ctx, `
|
||||
SELECT COALESCE(MAX(revision), 0) + 1
|
||||
FROM star_gift_catalog_revisions
|
||||
WHERE gift_id = $1`, giftID).Scan(&revision); err != nil {
|
||||
return fmt.Errorf("lock star gift catalog: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
media := NewMediaStore(tx)
|
||||
if err := media.PutDocument(ctx, write.Document); err != nil {
|
||||
return fmt.Errorf("put star gift document: %w", err)
|
||||
}
|
||||
if err := media.PutFileBlob(ctx, write.Blob); err != nil {
|
||||
return fmt.Errorf("put star gift blob: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO star_gift_catalog_revisions (
|
||||
id, gift_id, revision, title, stars, convert_stars, document_id,
|
||||
animation_json, animation_sha256, source_name, source_format,
|
||||
width, height, frame_rate, in_point, out_point, created_by, command_id
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8::jsonb,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18)`,
|
||||
revisionID, giftID, revision, write.Title, write.Stars, write.ConvertStars, write.Document.ID,
|
||||
string(write.Animation.JSON), write.Animation.SHA256, write.Animation.SourceName, string(write.Animation.SourceFormat),
|
||||
write.Animation.Width, write.Animation.Height, write.Animation.FrameRate, write.Animation.InPoint, write.Animation.OutPoint,
|
||||
write.Actor, write.CommandID,
|
||||
); err != nil {
|
||||
return fmt.Errorf("insert star gift revision: %w", err)
|
||||
}
|
||||
if write.GiftID != 0 {
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE star_gift_catalog
|
||||
SET active_revision_id=$2, enabled=$3, sort_order=$4, updated_at=now()
|
||||
WHERE gift_id=$1`, giftID, revisionID, write.Enabled, write.SortOrder); err != nil {
|
||||
return fmt.Errorf("activate star gift revision: %w", err)
|
||||
}
|
||||
}
|
||||
write.GiftID = giftID
|
||||
var err error
|
||||
entry, err = catalogEntryByID(ctx, tx, giftID)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return domain.StarGiftCatalogEntry{}, err
|
||||
}
|
||||
return entry, nil
|
||||
}
|
||||
|
||||
func (s *StarGiftStore) SetCatalogEnabled(ctx context.Context, giftID int64, enabled bool) (bool, error) {
|
||||
tag, err := s.db.Exec(ctx, `
|
||||
UPDATE star_gift_catalog SET enabled=$2, updated_at=now()
|
||||
WHERE gift_id=$1 AND enabled IS DISTINCT FROM $2`, giftID, enabled)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("set star gift enabled: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() > 0 {
|
||||
return true, nil
|
||||
}
|
||||
var exists bool
|
||||
if err := s.db.QueryRow(ctx, `SELECT EXISTS (SELECT 1 FROM star_gift_catalog WHERE gift_id=$1)`, giftID).Scan(&exists); err != nil {
|
||||
return false, fmt.Errorf("check star gift enabled target: %w", err)
|
||||
}
|
||||
if !exists {
|
||||
return false, domain.ErrStarGiftNotFound
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (s *StarGiftStore) SetCatalogSortOrder(ctx context.Context, giftID int64, sortOrder int) (bool, error) {
|
||||
tag, err := s.db.Exec(ctx, `
|
||||
UPDATE star_gift_catalog SET sort_order=$2, updated_at=now()
|
||||
WHERE gift_id=$1 AND sort_order IS DISTINCT FROM $2`, giftID, sortOrder)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("set star gift sort order: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() > 0 {
|
||||
return true, nil
|
||||
}
|
||||
var exists bool
|
||||
if err := s.db.QueryRow(ctx, `SELECT EXISTS (SELECT 1 FROM star_gift_catalog WHERE gift_id=$1)`, giftID).Scan(&exists); err != nil {
|
||||
return false, fmt.Errorf("check star gift sort target: %w", err)
|
||||
}
|
||||
if !exists {
|
||||
return false, domain.ErrStarGiftNotFound
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (s *StarGiftStore) AnimationJSON(ctx context.Context, giftID int64) ([]byte, bool, error) {
|
||||
var raw []byte
|
||||
err := s.db.QueryRow(ctx, `
|
||||
SELECT r.animation_json::text
|
||||
FROM star_gift_catalog c
|
||||
JOIN star_gift_catalog_revisions r ON r.id = c.active_revision_id
|
||||
WHERE c.gift_id=$1`, giftID).Scan(&raw)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("get star gift animation: %w", err)
|
||||
}
|
||||
return raw, true, nil
|
||||
}
|
||||
|
||||
func catalogEntryByID(ctx context.Context, db sqlcgen.DBTX, giftID int64) (domain.StarGiftCatalogEntry, error) {
|
||||
row := db.QueryRow(ctx, `
|
||||
SELECT c.gift_id, r.id, r.stars, r.convert_stars, r.title,
|
||||
COALESCE(cr.upgrade_stars, 0), COALESCE(cr.supply_total, 0), COALESCE(cr.issued, 0),
|
||||
d.id, d.access_hash, d.file_reference, d.date, d.mime_type, d.size, d.dc_id,
|
||||
d.attributes::text, d.thumbs::text,
|
||||
c.enabled, c.sort_order, r.revision, r.source_name, r.source_format,
|
||||
r.animation_sha256, r.width, r.height, r.frame_rate, r.created_by, c.updated_at,
|
||||
(SELECT COUNT(*) FROM peer_star_gifts p WHERE p.gift_id=c.gift_id)
|
||||
FROM star_gift_catalog c
|
||||
JOIN star_gift_catalog_revisions r ON r.id=c.active_revision_id
|
||||
LEFT JOIN star_gift_collectible_revisions cr ON cr.id=c.collectible_revision_id AND cr.status='published'
|
||||
JOIN documents d ON d.id=r.document_id
|
||||
WHERE c.gift_id=$1`, giftID)
|
||||
var entry domain.StarGiftCatalogEntry
|
||||
var attrsJSON, thumbsJSON, sourceFormat string
|
||||
if err := row.Scan(
|
||||
&entry.Gift.ID, &entry.Gift.RevisionID, &entry.Gift.Stars, &entry.Gift.ConvertStars, &entry.Gift.Title,
|
||||
&entry.Gift.UpgradeStars, &entry.Gift.UpgradeTotal, &entry.Gift.UpgradeIssued,
|
||||
&entry.Gift.Sticker.ID, &entry.Gift.Sticker.AccessHash, &entry.Gift.Sticker.FileReference, &entry.Gift.Sticker.Date,
|
||||
&entry.Gift.Sticker.MimeType, &entry.Gift.Sticker.Size, &entry.Gift.Sticker.DCID, &attrsJSON, &thumbsJSON,
|
||||
&entry.Enabled, &entry.SortOrder, &entry.Revision, &entry.SourceName, &sourceFormat,
|
||||
&entry.AnimationSHA, &entry.Width, &entry.Height, &entry.FrameRate, &entry.CreatedBy, &entry.UpdatedAt,
|
||||
&entry.ReceivedCount,
|
||||
); err != nil {
|
||||
return domain.StarGiftCatalogEntry{}, err
|
||||
}
|
||||
attrs, err := decodeDocumentAttributes(attrsJSON)
|
||||
if err != nil {
|
||||
return domain.StarGiftCatalogEntry{}, err
|
||||
}
|
||||
thumbs, err := decodePhotoSizes(thumbsJSON)
|
||||
if err != nil {
|
||||
return domain.StarGiftCatalogEntry{}, err
|
||||
}
|
||||
entry.Gift.Sticker.Attributes = attrs
|
||||
entry.Gift.Sticker.Thumbs = thumbs
|
||||
entry.SourceFormat = domain.StarGiftAnimationFormat(sourceFormat)
|
||||
entry.AnimationSize = entry.Gift.Sticker.Size
|
||||
return entry, nil
|
||||
}
|
||||
|
||||
func (s *StarGiftStore) Create(ctx context.Context, gift domain.SavedStarGift) (int64, error) {
|
||||
if !validSavedStarGift(gift) {
|
||||
return 0, domain.ErrStarGiftInvalid
|
||||
|
|
@ -30,14 +315,14 @@ func (s *StarGiftStore) Create(ctx context.Context, gift domain.SavedStarGift) (
|
|||
WITH next_id AS (
|
||||
SELECT nextval(pg_get_serial_sequence('public.peer_star_gifts', 'id'))::bigint AS id
|
||||
)
|
||||
INSERT INTO peer_star_gifts (id, owner_peer_type, owner_peer_id, from_user_id, gift_id, msg_id, saved_id, gift_date, name_hidden, unsaved, converted, convert_stars, message)
|
||||
SELECT next_id.id, $1,$2,$3,$4,$5,
|
||||
CASE WHEN $1 = 'channel' AND $6::bigint = 0 THEN next_id.id ELSE $6::bigint END,
|
||||
$7,$8,$9,false,$10,$11
|
||||
INSERT INTO peer_star_gifts (id, owner_peer_type, owner_peer_id, from_user_id, gift_id, catalog_revision_id, msg_id, saved_id, gift_date, name_hidden, unsaved, converted, convert_stars, prepaid_upgrade_stars, message)
|
||||
SELECT next_id.id, $1,$2,$3,$4,$5,$6,
|
||||
CASE WHEN $1 = 'channel' AND $7::bigint = 0 THEN next_id.id ELSE $7::bigint END,
|
||||
$8,$9,$10,false,$11,$12,$13
|
||||
FROM next_id
|
||||
RETURNING id`,
|
||||
string(gift.Owner.Type), gift.Owner.ID, gift.FromUserID, gift.GiftID, gift.MsgID, gift.SavedID, gift.Date,
|
||||
gift.NameHidden, gift.Unsaved, gift.ConvertStars, gift.Message).Scan(&id)
|
||||
string(gift.Owner.Type), gift.Owner.ID, gift.FromUserID, gift.GiftID, gift.RevisionID, gift.MsgID, gift.SavedID, gift.Date,
|
||||
gift.NameHidden, gift.Unsaved, gift.ConvertStars, gift.PrepaidUpgradeStars, gift.Message).Scan(&id)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("create star gift: %w", err)
|
||||
}
|
||||
|
|
@ -45,38 +330,80 @@ RETURNING id`,
|
|||
}
|
||||
|
||||
func (s *StarGiftStore) ListByOwner(ctx context.Context, owner domain.Peer, excludeUnsaved bool, offset string, limit int) (domain.SavedStarGiftPage, error) {
|
||||
return s.ListByOwnerFiltered(ctx, domain.SavedStarGiftFilter{
|
||||
Owner: owner, ExcludeUnsaved: excludeUnsaved, Offset: offset, Limit: limit,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *StarGiftStore) ListByOwnerFiltered(ctx context.Context, filter domain.SavedStarGiftFilter) (domain.SavedStarGiftPage, error) {
|
||||
owner, offset, limit := filter.Owner, filter.Offset, filter.Limit
|
||||
if !validStarGiftOwner(owner) {
|
||||
return domain.SavedStarGiftPage{}, nil
|
||||
}
|
||||
if limit <= 0 || limit > domain.MaxSavedStarGiftsLimit {
|
||||
limit = domain.MaxSavedStarGiftsLimit
|
||||
}
|
||||
// 总数(未转换 + 可选 excludeUnsaved 过滤)。
|
||||
countQuery := `SELECT COUNT(*) FROM peer_star_gifts WHERE owner_peer_type = $1 AND owner_peer_id = $2 AND NOT converted`
|
||||
if excludeUnsaved {
|
||||
countQuery += ` AND NOT unsaved`
|
||||
joins := `
|
||||
JOIN star_gift_catalog c ON c.gift_id = p.gift_id
|
||||
LEFT JOIN star_gift_collectible_revisions acr
|
||||
ON acr.id = c.collectible_revision_id AND acr.status = 'published'`
|
||||
conditions := []string{"p.owner_peer_type = $1", "p.owner_peer_id = $2", "NOT p.converted"}
|
||||
args := []any{string(owner.Type), owner.ID}
|
||||
if filter.ExcludeUnsaved {
|
||||
conditions = append(conditions, "NOT p.unsaved")
|
||||
}
|
||||
if filter.ExcludeSaved {
|
||||
conditions = append(conditions, "p.unsaved")
|
||||
}
|
||||
if filter.ExcludeUnique {
|
||||
conditions = append(conditions, "p.unique_gift_id IS NULL")
|
||||
}
|
||||
// telesrv ordinary catalog gifts are currently unlimited. Unique gifts are
|
||||
// collectibles and therefore survive exclude_unlimited.
|
||||
if filter.ExcludeUnlimited {
|
||||
conditions = append(conditions, "p.unique_gift_id IS NOT NULL")
|
||||
}
|
||||
upgradable := `(p.unique_gift_id IS NULL AND acr.id IS NOT NULL AND acr.upgrade_stars > 0 AND acr.issued < acr.supply_total)`
|
||||
if filter.ExcludeUpgradable {
|
||||
conditions = append(conditions, "NOT "+upgradable)
|
||||
}
|
||||
if filter.ExcludeUnupgradable {
|
||||
conditions = append(conditions, upgradable)
|
||||
}
|
||||
if filter.CollectionID > 0 {
|
||||
args = append(args, filter.CollectionID)
|
||||
conditions = append(conditions, fmt.Sprintf(`EXISTS (
|
||||
SELECT 1 FROM star_gift_collection_items ci
|
||||
JOIN star_gift_collections cc ON cc.collection_id = ci.collection_id
|
||||
WHERE ci.saved_gift_id = p.id AND ci.collection_id = $%d
|
||||
AND cc.owner_peer_type = p.owner_peer_type AND cc.owner_peer_id = p.owner_peer_id)`, len(args)))
|
||||
}
|
||||
where := strings.Join(conditions, " AND ")
|
||||
countQuery := `SELECT COUNT(*) FROM peer_star_gifts p ` + joins + ` WHERE ` + where
|
||||
var total int
|
||||
if err := s.db.QueryRow(ctx, countQuery, string(owner.Type), owner.ID).Scan(&total); err != nil {
|
||||
if err := s.db.QueryRow(ctx, countQuery, args...).Scan(&total); err != nil {
|
||||
return domain.SavedStarGiftPage{}, fmt.Errorf("count star gifts: %w", err)
|
||||
}
|
||||
page := domain.SavedStarGiftPage{Count: total}
|
||||
|
||||
where := "owner_peer_type = $1 AND owner_peer_id = $2 AND NOT converted"
|
||||
if excludeUnsaved {
|
||||
where += " AND NOT unsaved"
|
||||
}
|
||||
args := []any{string(owner.Type), owner.ID, limit + 1}
|
||||
if cursor, ok := domain.DecodeStarGiftCursor(offset); ok {
|
||||
where += " AND id < $4"
|
||||
args = append(args, cursor)
|
||||
where += fmt.Sprintf(" AND p.id < $%d", len(args))
|
||||
}
|
||||
args = append(args, limit+1)
|
||||
limitPlaceholder := len(args)
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT id, owner_peer_type, owner_peer_id, from_user_id, gift_id, msg_id, saved_id, gift_date, name_hidden, unsaved, converted, convert_stars, message
|
||||
FROM peer_star_gifts
|
||||
SELECT p.id, p.owner_peer_type, p.owner_peer_id, p.from_user_id, p.gift_id, p.catalog_revision_id,
|
||||
p.msg_id, p.saved_id, p.gift_date, p.name_hidden, p.unsaved, p.converted, p.convert_stars, p.prepaid_upgrade_stars,
|
||||
p.message, COALESCE(p.unique_gift_id, 0), p.upgrade_msg_id, p.pinned_order,
|
||||
COALESCE((SELECT array_agg(i.collection_id ORDER BY c.sort_order, i.collection_id)
|
||||
FROM star_gift_collection_items i
|
||||
JOIN star_gift_collections c ON c.collection_id=i.collection_id
|
||||
WHERE i.saved_gift_id=p.id), ARRAY[]::integer[])
|
||||
FROM peer_star_gifts p `+joins+`
|
||||
WHERE `+where+`
|
||||
ORDER BY id DESC
|
||||
LIMIT $3`, args...)
|
||||
ORDER BY p.id DESC
|
||||
LIMIT $`+fmt.Sprint(limitPlaceholder), args...)
|
||||
if err != nil {
|
||||
return domain.SavedStarGiftPage{}, fmt.Errorf("list star gifts: %w", err)
|
||||
}
|
||||
|
|
@ -100,14 +427,73 @@ LIMIT $3`, args...)
|
|||
return page, nil
|
||||
}
|
||||
|
||||
func (s *StarGiftStore) ResolveSavedIDs(ctx context.Context, owner domain.Peer, refs []domain.SavedStarGiftRef) ([]int64, error) {
|
||||
if !validStarGiftOwner(owner) || len(refs) > domain.MaxStarGiftCollectionItems {
|
||||
return nil, domain.ErrStarGiftCollectibleInvalid
|
||||
}
|
||||
if len(refs) == 0 {
|
||||
return []int64{}, nil
|
||||
}
|
||||
values := make([]int64, 0, len(refs))
|
||||
seenValues := make(map[int64]struct{}, len(refs))
|
||||
column := "msg_id"
|
||||
for _, ref := range refs {
|
||||
if ref.Owner != owner || !ref.Valid() {
|
||||
return nil, domain.ErrStarGiftNotFound
|
||||
}
|
||||
value := int64(ref.MsgID)
|
||||
if owner.Type == domain.PeerTypeChannel {
|
||||
column = "saved_id"
|
||||
value = ref.SavedID
|
||||
}
|
||||
if _, duplicate := seenValues[value]; duplicate {
|
||||
return nil, domain.ErrStarGiftCollectibleInvalid
|
||||
}
|
||||
seenValues[value] = struct{}{}
|
||||
values = append(values, value)
|
||||
}
|
||||
rows, err := s.db.Query(ctx, `SELECT `+column+`::bigint, id FROM peer_star_gifts
|
||||
WHERE owner_peer_type=$1 AND owner_peer_id=$2 AND NOT converted AND `+column+`::bigint=ANY($3::bigint[])`, string(owner.Type), owner.ID, values)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve saved star gifts: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
resolved := make(map[int64]int64, len(values))
|
||||
for rows.Next() {
|
||||
var value, id int64
|
||||
if err := rows.Scan(&value, &id); err != nil {
|
||||
return nil, fmt.Errorf("scan resolved saved star gift: %w", err)
|
||||
}
|
||||
resolved[value] = id
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate resolved saved star gifts: %w", err)
|
||||
}
|
||||
out := make([]int64, 0, len(values))
|
||||
for _, value := range values {
|
||||
id := resolved[value]
|
||||
if id == 0 {
|
||||
return nil, domain.ErrStarGiftNotFound
|
||||
}
|
||||
out = append(out, id)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *StarGiftStore) GetByRef(ctx context.Context, ref domain.SavedStarGiftRef) (domain.SavedStarGift, bool, error) {
|
||||
if !ref.Valid() {
|
||||
return domain.SavedStarGift{}, false, nil
|
||||
}
|
||||
where, args := savedStarGiftRefWhere(ref)
|
||||
row := s.db.QueryRow(ctx, `
|
||||
SELECT id, owner_peer_type, owner_peer_id, from_user_id, gift_id, msg_id, saved_id, gift_date, name_hidden, unsaved, converted, convert_stars, message
|
||||
FROM peer_star_gifts
|
||||
SELECT p.id, p.owner_peer_type, p.owner_peer_id, p.from_user_id, p.gift_id, p.catalog_revision_id,
|
||||
p.msg_id, p.saved_id, p.gift_date, p.name_hidden, p.unsaved, p.converted, p.convert_stars, p.prepaid_upgrade_stars,
|
||||
p.message, COALESCE(p.unique_gift_id, 0), p.upgrade_msg_id, p.pinned_order,
|
||||
COALESCE((SELECT array_agg(i.collection_id ORDER BY c.sort_order, i.collection_id)
|
||||
FROM star_gift_collection_items i
|
||||
JOIN star_gift_collections c ON c.collection_id=i.collection_id
|
||||
WHERE i.saved_gift_id=p.id), ARRAY[]::integer[])
|
||||
FROM peer_star_gifts p
|
||||
WHERE `+where, args...)
|
||||
g, err := scanSavedStarGift(row)
|
||||
if err != nil {
|
||||
|
|
@ -151,10 +537,19 @@ func (s *StarGiftStore) MarkConverted(ctx context.Context, ref domain.SavedStarG
|
|||
}
|
||||
out := domain.SavedStarGift{}
|
||||
err := withTx(ctx, s.db, "convert star gift", func(tx pgx.Tx) error {
|
||||
if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1, 0))`, starGiftCollectionLockKey(ref.Owner)); err != nil {
|
||||
return fmt.Errorf("lock star gift owner collections: %w", err)
|
||||
}
|
||||
where, args := savedStarGiftRefWhere(ref)
|
||||
row := tx.QueryRow(ctx, `
|
||||
SELECT id, owner_peer_type, owner_peer_id, from_user_id, gift_id, msg_id, saved_id, gift_date, name_hidden, unsaved, converted, convert_stars, message
|
||||
FROM peer_star_gifts
|
||||
SELECT p.id, p.owner_peer_type, p.owner_peer_id, p.from_user_id, p.gift_id, p.catalog_revision_id,
|
||||
p.msg_id, p.saved_id, p.gift_date, p.name_hidden, p.unsaved, p.converted, p.convert_stars, p.prepaid_upgrade_stars,
|
||||
p.message, COALESCE(p.unique_gift_id, 0), p.upgrade_msg_id, p.pinned_order,
|
||||
COALESCE((SELECT array_agg(i.collection_id ORDER BY c.sort_order, i.collection_id)
|
||||
FROM star_gift_collection_items i
|
||||
JOIN star_gift_collections c ON c.collection_id=i.collection_id
|
||||
WHERE i.saved_gift_id=p.id), ARRAY[]::integer[])
|
||||
FROM peer_star_gifts p
|
||||
WHERE `+where+` FOR UPDATE`, args...)
|
||||
g, err := scanSavedStarGift(row)
|
||||
if err != nil {
|
||||
|
|
@ -166,11 +561,19 @@ WHERE `+where+` FOR UPDATE`, args...)
|
|||
if g.Converted {
|
||||
return domain.ErrStarGiftAlreadyConverted
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `UPDATE peer_star_gifts SET converted = true, unsaved = true WHERE id = $1`, g.ID); err != nil {
|
||||
if g.UniqueGiftID != 0 {
|
||||
return domain.ErrStarGiftAlreadyUpgraded
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `UPDATE peer_star_gifts SET converted = true, unsaved = true, pinned_order = 0 WHERE id = $1`, g.ID); err != nil {
|
||||
return fmt.Errorf("mark star gift converted: %w", err)
|
||||
}
|
||||
if err := removeSavedGiftFromCollections(ctx, tx, g.Owner, g.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
g.Converted = true
|
||||
g.Unsaved = true
|
||||
g.PinnedOrder = 0
|
||||
g.CollectionIDs = nil
|
||||
out = g
|
||||
return nil
|
||||
})
|
||||
|
|
@ -183,8 +586,9 @@ WHERE `+where+` FOR UPDATE`, args...)
|
|||
func scanSavedStarGift(row rowScanner) (domain.SavedStarGift, error) {
|
||||
var g domain.SavedStarGift
|
||||
var ownerType string
|
||||
if err := row.Scan(&g.ID, &ownerType, &g.Owner.ID, &g.FromUserID, &g.GiftID, &g.MsgID, &g.SavedID, &g.Date,
|
||||
&g.NameHidden, &g.Unsaved, &g.Converted, &g.ConvertStars, &g.Message); err != nil {
|
||||
if err := row.Scan(&g.ID, &ownerType, &g.Owner.ID, &g.FromUserID, &g.GiftID, &g.RevisionID, &g.MsgID, &g.SavedID, &g.Date,
|
||||
&g.NameHidden, &g.Unsaved, &g.Converted, &g.ConvertStars, &g.PrepaidUpgradeStars, &g.Message, &g.UniqueGiftID,
|
||||
&g.UpgradeMsgID, &g.PinnedOrder, &g.CollectionIDs); err != nil {
|
||||
return domain.SavedStarGift{}, err
|
||||
}
|
||||
g.Owner.Type = domain.PeerType(ownerType)
|
||||
|
|
@ -204,7 +608,7 @@ func savedStarGiftRefWhere(ref domain.SavedStarGiftRef) (string, []any) {
|
|||
}
|
||||
|
||||
func validSavedStarGift(g domain.SavedStarGift) bool {
|
||||
if g.GiftID == 0 || !validStarGiftOwner(g.Owner) {
|
||||
if g.GiftID == 0 || g.RevisionID == 0 || !validStarGiftOwner(g.Owner) {
|
||||
return false
|
||||
}
|
||||
switch g.Owner.Type {
|
||||
|
|
|
|||
781
internal/store/postgres/star_gift_collectibles.go
Normal file
781
internal/store/postgres/star_gift_collectibles.go
Normal file
|
|
@ -0,0 +1,781 @@
|
|||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store/postgres/sqlcgen"
|
||||
)
|
||||
|
||||
func (s *StarGiftStore) PublishCollectibleRevision(ctx context.Context, write domain.StarGiftCollectibleWrite) (domain.StarGiftCollectibleRevision, error) {
|
||||
write.SlugPrefix = strings.ToLower(strings.TrimSpace(write.SlugPrefix))
|
||||
write.Actor = strings.TrimSpace(write.Actor)
|
||||
write.CommandID = strings.TrimSpace(write.CommandID)
|
||||
if err := domain.ValidateStarGiftCollectibleWrite(write); err != nil {
|
||||
return domain.StarGiftCollectibleRevision{}, err
|
||||
}
|
||||
var result domain.StarGiftCollectibleRevision
|
||||
err := withTx(ctx, s.db, "publish collectible star gift revision", func(tx pgx.Tx) error {
|
||||
var ignored int64
|
||||
if err := tx.QueryRow(ctx, `SELECT gift_id FROM star_gift_catalog WHERE gift_id=$1 FOR UPDATE`, write.GiftID).Scan(&ignored); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.ErrStarGiftNotFound
|
||||
}
|
||||
return fmt.Errorf("lock collectible catalog gift: %w", err)
|
||||
}
|
||||
var revision int
|
||||
if err := tx.QueryRow(ctx, `
|
||||
SELECT COALESCE(MAX(revision), 0) + 1 FROM star_gift_collectible_revisions WHERE gift_id=$1`, write.GiftID).Scan(&revision); err != nil {
|
||||
return fmt.Errorf("allocate collectible revision: %w", err)
|
||||
}
|
||||
var revisionID int64
|
||||
if err := tx.QueryRow(ctx, `
|
||||
INSERT INTO star_gift_collectible_revisions
|
||||
(gift_id, revision, upgrade_stars, supply_total, slug_prefix, status, created_by, command_id)
|
||||
VALUES ($1,$2,$3,$4,$5,'draft',$6,$7)
|
||||
RETURNING id`, write.GiftID, revision, write.UpgradeStars, write.SupplyTotal, write.SlugPrefix, write.Actor, write.CommandID).Scan(&revisionID); err != nil {
|
||||
return fmt.Errorf("insert collectible revision: %w", err)
|
||||
}
|
||||
media := NewMediaStore(tx)
|
||||
insertAnimated := func(table string, attributes []domain.StarGiftCollectibleAttribute) error {
|
||||
for _, attribute := range attributes {
|
||||
if err := media.PutDocument(ctx, *attribute.Document); err != nil {
|
||||
return fmt.Errorf("put collectible %s document: %w", attribute.Kind, err)
|
||||
}
|
||||
if err := media.PutFileBlob(ctx, *attribute.Blob); err != nil {
|
||||
return fmt.Errorf("put collectible %s blob: %w", attribute.Kind, err)
|
||||
}
|
||||
animation := attribute.Animation
|
||||
query := fmt.Sprintf(`
|
||||
INSERT INTO %s
|
||||
(collectible_revision_id, name, document_id, animation_json, animation_sha256,
|
||||
source_name, source_format, width, height, frame_rate, in_point, out_point,
|
||||
rarity_permille, sort_order)
|
||||
VALUES ($1,$2,$3,$4::jsonb,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)`, table)
|
||||
if _, err := tx.Exec(ctx, query, revisionID, strings.TrimSpace(attribute.Name), attribute.Document.ID,
|
||||
string(animation.JSON), animation.SHA256, animation.SourceName, string(animation.SourceFormat),
|
||||
animation.Width, animation.Height, animation.FrameRate, animation.InPoint, animation.OutPoint,
|
||||
attribute.RarityPermille, attribute.SortOrder); err != nil {
|
||||
return fmt.Errorf("insert collectible %s attribute: %w", attribute.Kind, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err := insertAnimated("star_gift_collectible_models", write.Models); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := insertAnimated("star_gift_collectible_patterns", write.Patterns); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, attribute := range write.Backdrops {
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO star_gift_collectible_backdrops
|
||||
(collectible_revision_id, name, backdrop_id, center_color, edge_color, pattern_color,
|
||||
text_color, rarity_permille, sort_order)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`, revisionID, strings.TrimSpace(attribute.Name), attribute.BackdropID,
|
||||
attribute.CenterColor, attribute.EdgeColor, attribute.PatternColor, attribute.TextColor,
|
||||
attribute.RarityPermille, attribute.SortOrder); err != nil {
|
||||
return fmt.Errorf("insert collectible backdrop: %w", err)
|
||||
}
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE star_gift_collectible_revisions SET status='published', published_at=now() WHERE id=$1`, revisionID); err != nil {
|
||||
return fmt.Errorf("publish collectible revision: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE star_gift_catalog SET collectible_revision_id=$2, updated_at=now() WHERE gift_id=$1`, write.GiftID, revisionID); err != nil {
|
||||
return fmt.Errorf("activate collectible revision: %w", err)
|
||||
}
|
||||
var err error
|
||||
result, err = collectibleRevisionByID(ctx, tx, revisionID)
|
||||
return err
|
||||
})
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (s *StarGiftStore) ActiveCollectibleRevision(ctx context.Context, giftID int64) (domain.StarGiftCollectibleRevision, bool, error) {
|
||||
var revisionID int64
|
||||
err := s.db.QueryRow(ctx, `
|
||||
SELECT collectible_revision_id FROM star_gift_catalog
|
||||
WHERE gift_id=$1 AND collectible_revision_id IS NOT NULL`, giftID).Scan(&revisionID)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.StarGiftCollectibleRevision{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return domain.StarGiftCollectibleRevision{}, false, fmt.Errorf("get active collectible revision: %w", err)
|
||||
}
|
||||
revision, err := collectibleRevisionByID(ctx, s.db, revisionID)
|
||||
if err != nil {
|
||||
return domain.StarGiftCollectibleRevision{}, false, err
|
||||
}
|
||||
return revision, true, nil
|
||||
}
|
||||
|
||||
func (s *StarGiftStore) CollectibleAvailability(ctx context.Context, giftIDs []int64) (map[int64]domain.StarGiftCollectibleAvailability, error) {
|
||||
out := make(map[int64]domain.StarGiftCollectibleAvailability, len(giftIDs))
|
||||
if len(giftIDs) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT c.gift_id, r.upgrade_stars, r.supply_total, r.issued
|
||||
FROM star_gift_catalog c
|
||||
JOIN star_gift_collectible_revisions r ON r.id=c.collectible_revision_id
|
||||
WHERE c.gift_id=ANY($1) AND r.status='published'`, giftIDs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list collectible availability: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var giftID int64
|
||||
var availability domain.StarGiftCollectibleAvailability
|
||||
if err := rows.Scan(&giftID, &availability.UpgradeStars, &availability.SupplyTotal, &availability.Issued); err != nil {
|
||||
return nil, fmt.Errorf("scan collectible availability: %w", err)
|
||||
}
|
||||
out[giftID] = availability
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("list collectible availability rows: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func collectibleRevisionByID(ctx context.Context, db sqlcgen.DBTX, revisionID int64) (domain.StarGiftCollectibleRevision, error) {
|
||||
var revision domain.StarGiftCollectibleRevision
|
||||
var status string
|
||||
var publishedAt pgtype.Timestamptz
|
||||
if err := db.QueryRow(ctx, `
|
||||
SELECT id, gift_id, revision, upgrade_stars, supply_total, issued, slug_prefix, status,
|
||||
created_by, created_at, published_at
|
||||
FROM star_gift_collectible_revisions WHERE id=$1`, revisionID).Scan(
|
||||
&revision.ID, &revision.GiftID, &revision.Revision, &revision.UpgradeStars, &revision.SupplyTotal,
|
||||
&revision.Issued, &revision.SlugPrefix, &status, &revision.CreatedBy, &revision.CreatedAt, &publishedAt,
|
||||
); err != nil {
|
||||
return domain.StarGiftCollectibleRevision{}, fmt.Errorf("get collectible revision: %w", err)
|
||||
}
|
||||
revision.Published = status == "published"
|
||||
if publishedAt.Valid {
|
||||
revision.PublishedAt = publishedAt.Time
|
||||
}
|
||||
var err error
|
||||
if revision.Models, err = listAnimatedCollectibleAttributes(ctx, db, revisionID, domain.StarGiftCollectibleModel); err != nil {
|
||||
return domain.StarGiftCollectibleRevision{}, err
|
||||
}
|
||||
if revision.Patterns, err = listAnimatedCollectibleAttributes(ctx, db, revisionID, domain.StarGiftCollectiblePattern); err != nil {
|
||||
return domain.StarGiftCollectibleRevision{}, err
|
||||
}
|
||||
if revision.Backdrops, err = listCollectibleBackdrops(ctx, db, revisionID); err != nil {
|
||||
return domain.StarGiftCollectibleRevision{}, err
|
||||
}
|
||||
return revision, nil
|
||||
}
|
||||
|
||||
func listAnimatedCollectibleAttributes(ctx context.Context, db sqlcgen.DBTX, revisionID int64, kind domain.StarGiftCollectibleAttributeKind) ([]domain.StarGiftCollectibleAttribute, error) {
|
||||
table := "star_gift_collectible_models"
|
||||
if kind == domain.StarGiftCollectiblePattern {
|
||||
table = "star_gift_collectible_patterns"
|
||||
} else if kind != domain.StarGiftCollectibleModel {
|
||||
return nil, domain.ErrStarGiftCollectibleInvalid
|
||||
}
|
||||
rows, err := db.Query(ctx, fmt.Sprintf(`
|
||||
SELECT a.id, a.collectible_revision_id, a.name, a.rarity_permille, a.sort_order,
|
||||
a.animation_json::text, a.animation_sha256, a.source_name, a.source_format,
|
||||
a.width, a.height, a.frame_rate, a.in_point, a.out_point,
|
||||
d.id, d.access_hash, d.file_reference, d.date, d.mime_type, d.size, d.dc_id,
|
||||
d.attributes::text, d.thumbs::text
|
||||
FROM %s a JOIN documents d ON d.id=a.document_id
|
||||
WHERE a.collectible_revision_id=$1 ORDER BY a.sort_order, a.id`, table), revisionID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list collectible %s attributes: %w", kind, err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]domain.StarGiftCollectibleAttribute, 0)
|
||||
for rows.Next() {
|
||||
attribute := domain.StarGiftCollectibleAttribute{Kind: kind, Document: &domain.Document{}, Animation: &domain.StarGiftAnimation{}}
|
||||
var attrsJSON, thumbsJSON, sourceFormat string
|
||||
if err := rows.Scan(&attribute.ID, &attribute.CollectibleRevisionID, &attribute.Name, &attribute.RarityPermille, &attribute.SortOrder,
|
||||
&attribute.Animation.JSON, &attribute.Animation.SHA256, &attribute.Animation.SourceName, &sourceFormat,
|
||||
&attribute.Animation.Width, &attribute.Animation.Height, &attribute.Animation.FrameRate, &attribute.Animation.InPoint, &attribute.Animation.OutPoint,
|
||||
&attribute.Document.ID, &attribute.Document.AccessHash, &attribute.Document.FileReference, &attribute.Document.Date,
|
||||
&attribute.Document.MimeType, &attribute.Document.Size, &attribute.Document.DCID, &attrsJSON, &thumbsJSON); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
attribute.Animation.SourceFormat = domain.StarGiftAnimationFormat(sourceFormat)
|
||||
if attribute.Document.Attributes, err = decodeDocumentAttributes(attrsJSON); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if attribute.Document.Thumbs, err = decodePhotoSizes(thumbsJSON); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, attribute)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func listCollectibleBackdrops(ctx context.Context, db sqlcgen.DBTX, revisionID int64) ([]domain.StarGiftCollectibleAttribute, error) {
|
||||
rows, err := db.Query(ctx, `
|
||||
SELECT id, collectible_revision_id, name, backdrop_id, center_color, edge_color, pattern_color,
|
||||
text_color, rarity_permille, sort_order
|
||||
FROM star_gift_collectible_backdrops WHERE collectible_revision_id=$1 ORDER BY sort_order, id`, revisionID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list collectible backdrops: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]domain.StarGiftCollectibleAttribute, 0)
|
||||
for rows.Next() {
|
||||
attribute := domain.StarGiftCollectibleAttribute{Kind: domain.StarGiftCollectibleBackdrop}
|
||||
if err := rows.Scan(&attribute.ID, &attribute.CollectibleRevisionID, &attribute.Name, &attribute.BackdropID,
|
||||
&attribute.CenterColor, &attribute.EdgeColor, &attribute.PatternColor, &attribute.TextColor,
|
||||
&attribute.RarityPermille, &attribute.SortOrder); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, attribute)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *StarGiftStore) CollectibleAnimationJSON(ctx context.Context, giftID int64, kind domain.StarGiftCollectibleAttributeKind, attributeID int64) ([]byte, bool, error) {
|
||||
table := "star_gift_collectible_models"
|
||||
if kind == domain.StarGiftCollectiblePattern {
|
||||
table = "star_gift_collectible_patterns"
|
||||
} else if kind != domain.StarGiftCollectibleModel {
|
||||
return nil, false, nil
|
||||
}
|
||||
var raw []byte
|
||||
err := s.db.QueryRow(ctx, fmt.Sprintf(`
|
||||
SELECT a.animation_json::text FROM %s a
|
||||
JOIN star_gift_catalog c ON c.collectible_revision_id=a.collectible_revision_id
|
||||
WHERE c.gift_id=$1 AND a.id=$2`, table), giftID, attributeID).Scan(&raw)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("get collectible animation: %w", err)
|
||||
}
|
||||
return raw, true, nil
|
||||
}
|
||||
|
||||
func (s *StarGiftStore) UniqueBySlug(ctx context.Context, slug string) (domain.UniqueStarGift, bool, error) {
|
||||
return s.uniqueByPredicate(ctx, "u.slug=$1", strings.ToLower(strings.TrimSpace(slug)))
|
||||
}
|
||||
|
||||
func (s *StarGiftStore) UniqueByID(ctx context.Context, uniqueGiftID int64) (domain.UniqueStarGift, bool, error) {
|
||||
return s.uniqueByPredicate(ctx, "u.id=$1", uniqueGiftID)
|
||||
}
|
||||
|
||||
func (s *StarGiftStore) UniqueByIDs(ctx context.Context, uniqueGiftIDs []int64) (map[int64]domain.UniqueStarGift, error) {
|
||||
out := make(map[int64]domain.UniqueStarGift, len(uniqueGiftIDs))
|
||||
if len(uniqueGiftIDs) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
rows, err := s.db.Query(ctx, uniqueStarGiftQuery("u.id=ANY($1::bigint[])"), uniqueGiftIDs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list unique star gifts: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
unique, err := scanUniqueStarGift(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[unique.ID] = unique
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate unique star gifts: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *StarGiftStore) uniqueByPredicate(ctx context.Context, predicate string, value any) (domain.UniqueStarGift, bool, error) {
|
||||
row := s.db.QueryRow(ctx, uniqueStarGiftQuery(predicate), value)
|
||||
unique, err := scanUniqueStarGift(row)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.UniqueStarGift{}, false, nil
|
||||
}
|
||||
return domain.UniqueStarGift{}, false, err
|
||||
}
|
||||
return unique, true, nil
|
||||
}
|
||||
|
||||
func uniqueStarGiftQuery(predicate string) string {
|
||||
return fmt.Sprintf(`
|
||||
SELECT u.id, u.gift_id, u.collectible_revision_id, u.source_saved_gift_id, u.title, u.slug, u.num,
|
||||
u.owner_peer_type, u.owner_peer_id, u.keep_original_details, u.created_at,
|
||||
r.issued, r.supply_total, sg.from_user_id, sg.owner_peer_type, sg.owner_peer_id,
|
||||
sg.gift_date, sg.message, sg.name_hidden,
|
||||
m.id, m.name, m.rarity_permille, md.id, md.access_hash, md.file_reference, md.date,
|
||||
md.mime_type, md.size, md.dc_id, md.attributes::text, md.thumbs::text,
|
||||
p.id, p.name, p.rarity_permille, pd.id, pd.access_hash, pd.file_reference, pd.date,
|
||||
pd.mime_type, pd.size, pd.dc_id, pd.attributes::text, pd.thumbs::text,
|
||||
b.id, b.name, b.backdrop_id, b.center_color, b.edge_color, b.pattern_color, b.text_color, b.rarity_permille
|
||||
FROM unique_star_gifts u
|
||||
JOIN star_gift_collectible_revisions r ON r.id=u.collectible_revision_id
|
||||
JOIN star_gift_collectible_models m ON m.id=u.model_attribute_id
|
||||
JOIN documents md ON md.id=m.document_id
|
||||
JOIN star_gift_collectible_patterns p ON p.id=u.pattern_attribute_id
|
||||
JOIN documents pd ON pd.id=p.document_id
|
||||
JOIN star_gift_collectible_backdrops b ON b.id=u.backdrop_attribute_id
|
||||
JOIN peer_star_gifts sg ON sg.id=u.source_saved_gift_id
|
||||
WHERE %s`, predicate)
|
||||
}
|
||||
|
||||
func scanUniqueStarGift(row rowScanner) (domain.UniqueStarGift, error) {
|
||||
var unique domain.UniqueStarGift
|
||||
var ownerType, originalOwnerType string
|
||||
unique.Model.Kind = domain.StarGiftCollectibleModel
|
||||
unique.Pattern.Kind = domain.StarGiftCollectiblePattern
|
||||
unique.Backdrop.Kind = domain.StarGiftCollectibleBackdrop
|
||||
unique.Model.Document = &domain.Document{}
|
||||
unique.Pattern.Document = &domain.Document{}
|
||||
var modelAttrs, modelThumbs, patternAttrs, patternThumbs string
|
||||
if err := row.Scan(&unique.ID, &unique.GiftID, &unique.CollectibleRevisionID, &unique.SourceSavedGiftID,
|
||||
&unique.Title, &unique.Slug, &unique.Num, &ownerType, &unique.Owner.ID, &unique.KeepOriginalDetails,
|
||||
&unique.CreatedAt, &unique.AvailabilityIssued, &unique.AvailabilityTotal,
|
||||
&unique.OriginalFromUserID, &originalOwnerType, &unique.OriginalOwner.ID, &unique.OriginalDate,
|
||||
&unique.OriginalMessage, &unique.OriginalNameHidden,
|
||||
&unique.Model.ID, &unique.Model.Name, &unique.Model.RarityPermille,
|
||||
&unique.Model.Document.ID, &unique.Model.Document.AccessHash, &unique.Model.Document.FileReference,
|
||||
&unique.Model.Document.Date, &unique.Model.Document.MimeType, &unique.Model.Document.Size,
|
||||
&unique.Model.Document.DCID, &modelAttrs, &modelThumbs,
|
||||
&unique.Pattern.ID, &unique.Pattern.Name, &unique.Pattern.RarityPermille,
|
||||
&unique.Pattern.Document.ID, &unique.Pattern.Document.AccessHash, &unique.Pattern.Document.FileReference,
|
||||
&unique.Pattern.Document.Date, &unique.Pattern.Document.MimeType, &unique.Pattern.Document.Size,
|
||||
&unique.Pattern.Document.DCID, &patternAttrs, &patternThumbs,
|
||||
&unique.Backdrop.ID, &unique.Backdrop.Name, &unique.Backdrop.BackdropID, &unique.Backdrop.CenterColor,
|
||||
&unique.Backdrop.EdgeColor, &unique.Backdrop.PatternColor, &unique.Backdrop.TextColor, &unique.Backdrop.RarityPermille); err != nil {
|
||||
return domain.UniqueStarGift{}, fmt.Errorf("get unique star gift: %w", err)
|
||||
}
|
||||
unique.Owner.Type = domain.PeerType(ownerType)
|
||||
unique.OriginalOwner.Type = domain.PeerType(originalOwnerType)
|
||||
unique.Model.CollectibleRevisionID = unique.CollectibleRevisionID
|
||||
unique.Pattern.CollectibleRevisionID = unique.CollectibleRevisionID
|
||||
unique.Backdrop.CollectibleRevisionID = unique.CollectibleRevisionID
|
||||
var err error
|
||||
if unique.Model.Document.Attributes, err = decodeDocumentAttributes(modelAttrs); err != nil {
|
||||
return domain.UniqueStarGift{}, err
|
||||
}
|
||||
if unique.Model.Document.Thumbs, err = decodePhotoSizes(modelThumbs); err != nil {
|
||||
return domain.UniqueStarGift{}, err
|
||||
}
|
||||
if unique.Pattern.Document.Attributes, err = decodeDocumentAttributes(patternAttrs); err != nil {
|
||||
return domain.UniqueStarGift{}, err
|
||||
}
|
||||
if unique.Pattern.Document.Thumbs, err = decodePhotoSizes(patternThumbs); err != nil {
|
||||
return domain.UniqueStarGift{}, err
|
||||
}
|
||||
return unique, nil
|
||||
}
|
||||
|
||||
func (s *StarGiftStore) ListCollections(ctx context.Context, owner domain.Peer) ([]domain.StarGiftCollection, error) {
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT c.collection_id, c.title, c.hash, c.sort_order, c.created_at, c.updated_at, i.saved_gift_id
|
||||
FROM star_gift_collections c
|
||||
LEFT JOIN star_gift_collection_items i ON i.collection_id=c.collection_id
|
||||
WHERE c.owner_peer_type=$1 AND c.owner_peer_id=$2
|
||||
ORDER BY c.sort_order, c.collection_id, i.sort_order, i.saved_gift_id`, string(owner.Type), owner.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list star gift collections: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]domain.StarGiftCollection, 0)
|
||||
index := make(map[int]int)
|
||||
for rows.Next() {
|
||||
var collection domain.StarGiftCollection
|
||||
var giftID pgtype.Int8
|
||||
if err := rows.Scan(&collection.CollectionID, &collection.Title, &collection.Hash, &collection.SortOrder,
|
||||
&collection.CreatedAt, &collection.UpdatedAt, &giftID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
position, ok := index[collection.CollectionID]
|
||||
if !ok {
|
||||
collection.Owner = owner
|
||||
position = len(out)
|
||||
index[collection.CollectionID] = position
|
||||
out = append(out, collection)
|
||||
}
|
||||
if giftID.Valid {
|
||||
out[position].GiftIDs = append(out[position].GiftIDs, giftID.Int64)
|
||||
}
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *StarGiftStore) CreateCollection(ctx context.Context, owner domain.Peer, title string, savedGiftIDs []int64) (domain.StarGiftCollection, error) {
|
||||
title = strings.TrimSpace(title)
|
||||
if !validPostgresStarGiftOwner(owner) || title == "" || len([]rune(title)) > domain.MaxStarGiftCollectionTitleRunes {
|
||||
return domain.StarGiftCollection{}, domain.ErrStarGiftCollectibleInvalid
|
||||
}
|
||||
var result domain.StarGiftCollection
|
||||
err := withTx(ctx, s.db, "create star gift collection", func(tx pgx.Tx) error {
|
||||
if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1, 0))`, starGiftCollectionLockKey(owner)); err != nil {
|
||||
return err
|
||||
}
|
||||
var count int
|
||||
if err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM star_gift_collections WHERE owner_peer_type=$1 AND owner_peer_id=$2`, string(owner.Type), owner.ID).Scan(&count); err != nil {
|
||||
return err
|
||||
}
|
||||
if count >= domain.MaxStarGiftCollectionsPerPeer {
|
||||
return domain.ErrStarGiftCollectionsFull
|
||||
}
|
||||
ids, err := validatePostgresCollectionGiftIDs(ctx, tx, owner, savedGiftIDs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result = domain.StarGiftCollection{Owner: owner, Title: title, GiftIDs: ids, SortOrder: count}
|
||||
result.Hash = domain.StarGiftCollectionHash(title, ids)
|
||||
if err := tx.QueryRow(ctx, `
|
||||
INSERT INTO star_gift_collections(owner_peer_type, owner_peer_id, title, sort_order, hash)
|
||||
VALUES ($1,$2,$3,$4,$5) RETURNING collection_id, created_at, updated_at`, string(owner.Type), owner.ID,
|
||||
title, count, result.Hash).Scan(&result.CollectionID, &result.CreatedAt, &result.UpdatedAt); err != nil {
|
||||
return err
|
||||
}
|
||||
return replaceCollectionItems(ctx, tx, result.CollectionID, ids)
|
||||
})
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (s *StarGiftStore) UpdateCollection(ctx context.Context, owner domain.Peer, collectionID int, patch domain.StarGiftCollectionPatch) (domain.StarGiftCollection, error) {
|
||||
var result domain.StarGiftCollection
|
||||
err := withTx(ctx, s.db, "update star gift collection", func(tx pgx.Tx) error {
|
||||
if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1, 0))`, starGiftCollectionLockKey(owner)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.QueryRow(ctx, `
|
||||
SELECT title, hash, sort_order, created_at, updated_at FROM star_gift_collections
|
||||
WHERE owner_peer_type=$1 AND owner_peer_id=$2 AND collection_id=$3 FOR UPDATE`, string(owner.Type), owner.ID, collectionID).Scan(
|
||||
&result.Title, &result.Hash, &result.SortOrder, &result.CreatedAt, &result.UpdatedAt); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.ErrStarGiftCollectionNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
result.Owner = owner
|
||||
result.CollectionID = collectionID
|
||||
rows, err := tx.Query(ctx, `SELECT saved_gift_id FROM star_gift_collection_items WHERE collection_id=$1 ORDER BY sort_order, saved_gift_id`, collectionID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
result.GiftIDs = append(result.GiftIDs, id)
|
||||
}
|
||||
rows.Close()
|
||||
if patch.Title != nil {
|
||||
title := strings.TrimSpace(*patch.Title)
|
||||
if title == "" || len([]rune(title)) > domain.MaxStarGiftCollectionTitleRunes {
|
||||
return domain.ErrStarGiftCollectibleInvalid
|
||||
}
|
||||
result.Title = title
|
||||
}
|
||||
deleted := make(map[int64]struct{}, len(patch.DeleteIDs))
|
||||
for _, id := range patch.DeleteIDs {
|
||||
deleted[id] = struct{}{}
|
||||
}
|
||||
next := make([]int64, 0, len(result.GiftIDs)+len(patch.AddIDs))
|
||||
for _, id := range result.GiftIDs {
|
||||
if _, ok := deleted[id]; !ok {
|
||||
next = append(next, id)
|
||||
}
|
||||
}
|
||||
add, err := validatePostgresCollectionGiftIDs(ctx, tx, owner, patch.AddIDs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
next = appendUniquePostgresIDs(next, add...)
|
||||
if patch.Order != nil {
|
||||
order, err := validatePostgresCollectionGiftIDs(ctx, tx, owner, patch.Order)
|
||||
if err != nil || !samePostgresIDSet(order, next) {
|
||||
return domain.ErrStarGiftCollectibleInvalid
|
||||
}
|
||||
next = order
|
||||
}
|
||||
if len(next) > domain.MaxStarGiftCollectionItems {
|
||||
return domain.ErrStarGiftCollectibleInvalid
|
||||
}
|
||||
result.GiftIDs = next
|
||||
result.Hash = domain.StarGiftCollectionHash(result.Title, result.GiftIDs)
|
||||
if err := tx.QueryRow(ctx, `
|
||||
UPDATE star_gift_collections SET title=$4, hash=$5, updated_at=now()
|
||||
WHERE owner_peer_type=$1 AND owner_peer_id=$2 AND collection_id=$3 RETURNING updated_at`,
|
||||
string(owner.Type), owner.ID, collectionID, result.Title, result.Hash).Scan(&result.UpdatedAt); err != nil {
|
||||
return err
|
||||
}
|
||||
return replaceCollectionItems(ctx, tx, collectionID, result.GiftIDs)
|
||||
})
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (s *StarGiftStore) DeleteCollection(ctx context.Context, owner domain.Peer, collectionID int) (bool, error) {
|
||||
var changed bool
|
||||
err := withTx(ctx, s.db, "delete star gift collection", func(tx pgx.Tx) error {
|
||||
if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1, 0))`, starGiftCollectionLockKey(owner)); err != nil {
|
||||
return err
|
||||
}
|
||||
tag, err := tx.Exec(ctx, `DELETE FROM star_gift_collections WHERE owner_peer_type=$1 AND owner_peer_id=$2 AND collection_id=$3`, string(owner.Type), owner.ID, collectionID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
changed = tag.RowsAffected() > 0
|
||||
if changed {
|
||||
_, err = tx.Exec(ctx, `
|
||||
WITH ordered AS (
|
||||
SELECT collection_id, row_number() OVER (ORDER BY sort_order, collection_id) - 1 AS next_order
|
||||
FROM star_gift_collections WHERE owner_peer_type=$1 AND owner_peer_id=$2
|
||||
)
|
||||
UPDATE star_gift_collections c SET sort_order=o.next_order, updated_at=now()
|
||||
FROM ordered o WHERE c.collection_id=o.collection_id`, string(owner.Type), owner.ID)
|
||||
}
|
||||
return err
|
||||
})
|
||||
return changed, err
|
||||
}
|
||||
|
||||
func (s *StarGiftStore) ReorderCollections(ctx context.Context, owner domain.Peer, collectionIDs []int) error {
|
||||
return withTx(ctx, s.db, "reorder star gift collections", func(tx pgx.Tx) error {
|
||||
if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1, 0))`, starGiftCollectionLockKey(owner)); err != nil {
|
||||
return err
|
||||
}
|
||||
rows, err := tx.Query(ctx, `SELECT collection_id FROM star_gift_collections WHERE owner_peer_type=$1 AND owner_peer_id=$2 FOR UPDATE`, string(owner.Type), owner.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
existing := make([]int, 0)
|
||||
for rows.Next() {
|
||||
var id int
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
existing = append(existing, id)
|
||||
}
|
||||
rows.Close()
|
||||
if !samePostgresIntSet(existing, collectionIDs) {
|
||||
return domain.ErrStarGiftCollectibleInvalid
|
||||
}
|
||||
for order, id := range collectionIDs {
|
||||
if _, err := tx.Exec(ctx, `UPDATE star_gift_collections SET sort_order=$4, updated_at=now() WHERE owner_peer_type=$1 AND owner_peer_id=$2 AND collection_id=$3`, string(owner.Type), owner.ID, id, order); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (s *StarGiftStore) SetPinned(ctx context.Context, owner domain.Peer, savedGiftIDs []int64) error {
|
||||
return withTx(ctx, s.db, "set pinned star gifts", func(tx pgx.Tx) error {
|
||||
if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1, 0))`, starGiftCollectionLockKey(owner)); err != nil {
|
||||
return err
|
||||
}
|
||||
ids, err := validatePostgresCollectionGiftIDs(ctx, tx, owner, savedGiftIDs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `UPDATE peer_star_gifts SET pinned_order=0 WHERE owner_peer_type=$1 AND owner_peer_id=$2 AND pinned_order<>0`, string(owner.Type), owner.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
for order, id := range ids {
|
||||
if _, err := tx.Exec(ctx, `UPDATE peer_star_gifts SET pinned_order=$2 WHERE id=$1`, id, order+1); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func validatePostgresCollectionGiftIDs(ctx context.Context, db sqlcgen.DBTX, owner domain.Peer, ids []int64) ([]int64, error) {
|
||||
ids = dedupePostgresIDs(ids)
|
||||
if len(ids) > domain.MaxStarGiftCollectionItems {
|
||||
return nil, domain.ErrStarGiftCollectibleInvalid
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return []int64{}, nil
|
||||
}
|
||||
rows, err := db.Query(ctx, `
|
||||
SELECT id FROM peer_star_gifts
|
||||
WHERE owner_peer_type=$1 AND owner_peer_id=$2 AND NOT converted AND id=ANY($3::bigint[])
|
||||
FOR UPDATE`, string(owner.Type), owner.ID, ids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
found := make(map[int64]struct{}, len(ids))
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
found[id] = struct{}{}
|
||||
}
|
||||
if len(found) != len(ids) {
|
||||
return nil, domain.ErrStarGiftNotFound
|
||||
}
|
||||
return ids, rows.Err()
|
||||
}
|
||||
|
||||
// removeSavedGiftFromCollections runs under the owner advisory lock. It removes
|
||||
// terminal gifts and updates every affected collection hash in bounded batches,
|
||||
// so getStarGiftCollections cannot return NotModified for changed membership.
|
||||
func removeSavedGiftFromCollections(ctx context.Context, tx pgx.Tx, owner domain.Peer, savedGiftID int64) error {
|
||||
rows, err := tx.Query(ctx, `
|
||||
SELECT c.collection_id, c.title
|
||||
FROM star_gift_collections c
|
||||
JOIN star_gift_collection_items i ON i.collection_id=c.collection_id
|
||||
WHERE c.owner_peer_type=$1 AND c.owner_peer_id=$2 AND i.saved_gift_id=$3
|
||||
ORDER BY c.collection_id
|
||||
FOR UPDATE OF c`, string(owner.Type), owner.ID, savedGiftID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("lock converted gift collections: %w", err)
|
||||
}
|
||||
titles := make(map[int]string)
|
||||
ids := make([]int, 0)
|
||||
for rows.Next() {
|
||||
var id int
|
||||
var title string
|
||||
if err := rows.Scan(&id, &title); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
ids = append(ids, id)
|
||||
titles[id] = title
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
rows.Close()
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `DELETE FROM star_gift_collection_items WHERE saved_gift_id=$1`, savedGiftID); err != nil {
|
||||
return fmt.Errorf("remove converted gift collection memberships: %w", err)
|
||||
}
|
||||
|
||||
memberships := make(map[int][]int64, len(ids))
|
||||
itemRows, err := tx.Query(ctx, `
|
||||
SELECT collection_id, saved_gift_id
|
||||
FROM star_gift_collection_items
|
||||
WHERE collection_id=ANY($1::integer[])
|
||||
ORDER BY collection_id, sort_order, saved_gift_id`, ids)
|
||||
if err != nil {
|
||||
return fmt.Errorf("list remaining collection memberships: %w", err)
|
||||
}
|
||||
for itemRows.Next() {
|
||||
var collectionID int
|
||||
var giftID int64
|
||||
if err := itemRows.Scan(&collectionID, &giftID); err != nil {
|
||||
itemRows.Close()
|
||||
return err
|
||||
}
|
||||
memberships[collectionID] = append(memberships[collectionID], giftID)
|
||||
}
|
||||
if err := itemRows.Err(); err != nil {
|
||||
itemRows.Close()
|
||||
return err
|
||||
}
|
||||
itemRows.Close()
|
||||
|
||||
hashes := make([]int64, len(ids))
|
||||
for i, collectionID := range ids {
|
||||
hashes[i] = domain.StarGiftCollectionHash(titles[collectionID], memberships[collectionID])
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE star_gift_collections c SET hash=x.hash, updated_at=now()
|
||||
FROM unnest($1::integer[], $2::bigint[]) AS x(collection_id, hash)
|
||||
WHERE c.collection_id=x.collection_id`, ids, hashes); err != nil {
|
||||
return fmt.Errorf("refresh converted gift collection hashes: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func replaceCollectionItems(ctx context.Context, tx pgx.Tx, collectionID int, ids []int64) error {
|
||||
if _, err := tx.Exec(ctx, `DELETE FROM star_gift_collection_items WHERE collection_id=$1`, collectionID); err != nil {
|
||||
return err
|
||||
}
|
||||
for order, id := range ids {
|
||||
if _, err := tx.Exec(ctx, `INSERT INTO star_gift_collection_items(collection_id, saved_gift_id, sort_order) VALUES ($1,$2,$3)`, collectionID, id, order); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validPostgresStarGiftOwner(owner domain.Peer) bool {
|
||||
return owner.ID > 0 && (owner.Type == domain.PeerTypeUser || owner.Type == domain.PeerTypeChannel)
|
||||
}
|
||||
|
||||
func starGiftCollectionLockKey(owner domain.Peer) string {
|
||||
return fmt.Sprintf("star_gift_collection:%s:%d", owner.Type, owner.ID)
|
||||
}
|
||||
|
||||
func dedupePostgresIDs(ids []int64) []int64 {
|
||||
out := make([]int64, 0, len(ids))
|
||||
seen := make(map[int64]struct{}, len(ids))
|
||||
for _, id := range ids {
|
||||
if id <= 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[id]; !ok {
|
||||
seen[id] = struct{}{}
|
||||
out = append(out, id)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func appendUniquePostgresIDs(dst []int64, values ...int64) []int64 {
|
||||
seen := make(map[int64]struct{}, len(dst)+len(values))
|
||||
for _, id := range dst {
|
||||
seen[id] = struct{}{}
|
||||
}
|
||||
for _, id := range values {
|
||||
if _, ok := seen[id]; !ok {
|
||||
seen[id] = struct{}{}
|
||||
dst = append(dst, id)
|
||||
}
|
||||
}
|
||||
return dst
|
||||
}
|
||||
|
||||
func samePostgresIDSet(a, b []int64) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
a = append([]int64(nil), a...)
|
||||
b = append([]int64(nil), b...)
|
||||
sort.Slice(a, func(i, j int) bool { return a[i] < a[j] })
|
||||
sort.Slice(b, func(i, j int) bool { return b[i] < b[j] })
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func samePostgresIntSet(a, b []int) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
seen := make(map[int]struct{}, len(a))
|
||||
for _, id := range a {
|
||||
seen[id] = struct{}{}
|
||||
}
|
||||
for _, id := range b {
|
||||
if _, ok := seen[id]; !ok {
|
||||
return false
|
||||
}
|
||||
delete(seen, id)
|
||||
}
|
||||
return len(seen) == 0
|
||||
}
|
||||
|
|
@ -0,0 +1,429 @@
|
|||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func TestStarGiftCollectibleUpgradeAggregatePostgres(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
suffix := randomSuffix(t)
|
||||
users := NewUserStore(pool)
|
||||
sender := createTestUser(t, ctx, users, "+1778"+suffix+"41", "CollectibleSender", "")
|
||||
owner := createTestUser(t, ctx, users, "+1778"+suffix+"42", "CollectibleOwner", "")
|
||||
ownerPeer := domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID}
|
||||
|
||||
gifts := NewStarGiftStore(pool)
|
||||
baseDocumentID := time.Now().UnixNano() & 0x7ffffffffffff000
|
||||
entry, err := gifts.CreateCatalogRevision(ctx, domain.StarGiftCatalogWrite{
|
||||
Title: "Comet", Stars: 50, ConvertStars: 25, Enabled: true,
|
||||
Document: collectibleTestDocument(baseDocumentID, "gift.tgs"),
|
||||
Blob: collectibleTestBlob(baseDocumentID, "gift"),
|
||||
Animation: collectibleTestAnimation("gift.tgs"),
|
||||
Actor: "integration", CommandID: "catalog-" + suffix,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create collectible catalog gift: %v", err)
|
||||
}
|
||||
poolRevision, err := gifts.PublishCollectibleRevision(ctx, domain.StarGiftCollectibleWrite{
|
||||
GiftID: entry.Gift.ID, UpgradeStars: 100, SupplyTotal: 10, SlugPrefix: "comet-" + suffix,
|
||||
Models: []domain.StarGiftCollectibleAttribute{{
|
||||
Kind: domain.StarGiftCollectibleModel, Name: "Aurora", RarityPermille: 1000,
|
||||
Document: collectibleTestDocumentPtr(baseDocumentID+1, "model.tgs"),
|
||||
Blob: collectibleTestBlobPtr(baseDocumentID+1, "model"), Animation: collectibleTestAnimationPtr("model.tgs"),
|
||||
}},
|
||||
Patterns: []domain.StarGiftCollectibleAttribute{{
|
||||
Kind: domain.StarGiftCollectiblePattern, Name: "Orbit", RarityPermille: 1000,
|
||||
Document: collectibleTestDocumentPtr(baseDocumentID+2, "pattern.tgs"),
|
||||
Blob: collectibleTestBlobPtr(baseDocumentID+2, "pattern"), Animation: collectibleTestAnimationPtr("pattern.tgs"),
|
||||
}},
|
||||
Backdrops: []domain.StarGiftCollectibleAttribute{{
|
||||
Kind: domain.StarGiftCollectibleBackdrop, Name: "Midnight", BackdropID: 1,
|
||||
CenterColor: 0x112233, EdgeColor: 0x223344, PatternColor: 0x334455, TextColor: 0xffffff,
|
||||
RarityPermille: 1000,
|
||||
}},
|
||||
Actor: "integration", CommandID: "collectibles-" + suffix,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("publish collectible pool: %v", err)
|
||||
}
|
||||
if !poolRevision.Published || poolRevision.Issued != 0 || len(poolRevision.Models) != 1 || len(poolRevision.Patterns) != 1 || len(poolRevision.Backdrops) != 1 {
|
||||
t.Fatalf("published pool = %+v", poolRevision)
|
||||
}
|
||||
availability, err := gifts.CollectibleAvailability(ctx, []int64{entry.Gift.ID, entry.Gift.ID + 1})
|
||||
if err != nil {
|
||||
t.Fatalf("collectible availability: %v", err)
|
||||
}
|
||||
if got, ok := availability[entry.Gift.ID]; !ok || got.UpgradeStars != 100 || got.SupplyTotal != 10 || got.Issued != 0 {
|
||||
t.Fatalf("collectible availability = %+v, want active published pool", availability)
|
||||
}
|
||||
if _, ok := availability[entry.Gift.ID+1]; ok {
|
||||
t.Fatalf("unknown gift must not have collectible availability: %+v", availability)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, `UPDATE star_gift_collectible_revisions SET issued=issued WHERE id=$1`, poolRevision.ID); err == nil {
|
||||
t.Fatal("published collectible revision accepted a non-advancing issuance update")
|
||||
}
|
||||
var guardedIssued int
|
||||
if err := pool.QueryRow(ctx, `SELECT issued FROM star_gift_collectible_revisions WHERE id=$1`, poolRevision.ID).Scan(&guardedIssued); err != nil || guardedIssued != 0 {
|
||||
t.Fatalf("issued after rejected manual update = %d err %v, want 0", guardedIssued, err)
|
||||
}
|
||||
|
||||
savedID, err := gifts.Create(ctx, domain.SavedStarGift{
|
||||
Owner: ownerPeer, FromUserID: sender.ID, GiftID: entry.Gift.ID, RevisionID: entry.Gift.RevisionID,
|
||||
MsgID: 700001, Date: 1700001000, ConvertStars: 25, Message: "original",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create saved gift: %v", err)
|
||||
}
|
||||
stars := NewStarsStore(pool)
|
||||
if _, _, err := stars.EnsureGrant(ctx, owner.ID, 1000, 1700001001); err != nil {
|
||||
t.Fatalf("grant upgrade stars: %v", err)
|
||||
}
|
||||
messages := NewMessageStore(pool)
|
||||
upgrades := NewStarGiftUpgradeStore(pool, messages)
|
||||
req := domain.StarGiftUpgradeRequest{
|
||||
UserID: owner.ID, Ref: domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: 700001},
|
||||
KeepOriginalDetails: true, ChargeStars: 100, FormID: 991,
|
||||
CommandKey: "paid-" + suffix, Date: 1700001002,
|
||||
}
|
||||
upgraded, err := upgrades.UpgradeStarGift(ctx, req)
|
||||
if err != nil {
|
||||
t.Fatalf("upgrade star gift: %v", err)
|
||||
}
|
||||
if upgraded.Duplicate || upgraded.Unique.Num != 1 || upgraded.Unique.Slug != "comet-"+suffix+"-1" ||
|
||||
upgraded.Unique.Model.Name != "Aurora" || upgraded.Unique.Pattern.Name != "Orbit" ||
|
||||
upgraded.Unique.Backdrop.Name != "Midnight" || upgraded.Balance.Balance != 900 ||
|
||||
upgraded.Saved.ID != savedID || upgraded.Saved.UniqueGiftID != upgraded.Unique.ID || upgraded.Saved.UpgradeMsgID <= 0 {
|
||||
t.Fatalf("upgrade result = %+v", upgraded)
|
||||
}
|
||||
ownerMessage := upgraded.Send.RecipientMessage
|
||||
if ownerMessage.OwnerUserID != owner.ID || ownerMessage.Pts <= 0 || ownerMessage.Media == nil ||
|
||||
ownerMessage.Media.ServiceAction == nil || ownerMessage.Media.ServiceAction.Kind != domain.MessageServiceActionStarGiftUnique ||
|
||||
ownerMessage.Media.ServiceAction.StarGiftUnique == nil || ownerMessage.Media.ServiceAction.StarGiftUnique.Gift.ID != upgraded.Unique.ID {
|
||||
t.Fatalf("owner upgrade service message = %+v", ownerMessage)
|
||||
}
|
||||
|
||||
var (
|
||||
issued, uniqueCount, commandCount int
|
||||
reason string
|
||||
)
|
||||
if err := pool.QueryRow(ctx, `SELECT issued FROM star_gift_collectible_revisions WHERE id=$1`, poolRevision.ID).Scan(&issued); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM unique_star_gifts WHERE source_saved_gift_id=$1`, savedID).Scan(&uniqueCount); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM star_gift_upgrade_commands WHERE source_saved_gift_id=$1`, savedID).Scan(&commandCount); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := pool.QueryRow(ctx, `SELECT reason FROM stars_transactions WHERE user_id=$1 ORDER BY id DESC LIMIT 1`, owner.ID).Scan(&reason); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if issued != 1 || uniqueCount != 1 || commandCount != 1 || reason != string(domain.StarsReasonGiftUpgrade) {
|
||||
t.Fatalf("durable aggregate issued=%d unique=%d command=%d reason=%q", issued, uniqueCount, commandCount, reason)
|
||||
}
|
||||
|
||||
replayed, err := upgrades.UpgradeStarGift(ctx, req)
|
||||
if err != nil {
|
||||
t.Fatalf("replay upgrade: %v", err)
|
||||
}
|
||||
if !replayed.Duplicate || replayed.Unique.ID != upgraded.Unique.ID || replayed.Balance.Balance != 900 {
|
||||
t.Fatalf("replayed upgrade = %+v", replayed)
|
||||
}
|
||||
conflictingReplay := req
|
||||
conflictingReplay.KeepOriginalDetails = false
|
||||
if _, err := upgrades.UpgradeStarGift(ctx, conflictingReplay); err == nil {
|
||||
t.Fatal("same command key with a changed semantic payload must not replay")
|
||||
}
|
||||
if _, err := upgrades.UpgradeStarGift(ctx, domain.StarGiftUpgradeRequest{
|
||||
UserID: owner.ID, Ref: req.Ref, ChargeStars: 100, FormID: 992,
|
||||
CommandKey: "different-" + suffix, Date: 1700001003,
|
||||
}); !errors.Is(err, domain.ErrStarGiftAlreadyUpgraded) {
|
||||
t.Fatalf("second logical upgrade err = %v", err)
|
||||
}
|
||||
bal, err := stars.GetBalance(ctx, owner.ID)
|
||||
if err != nil || bal.Balance != 900 {
|
||||
t.Fatalf("balance after retries = %+v err %v", bal, err)
|
||||
}
|
||||
|
||||
prepaidSavedID, err := gifts.Create(ctx, domain.SavedStarGift{
|
||||
Owner: ownerPeer, FromUserID: sender.ID, GiftID: entry.Gift.ID, RevisionID: entry.Gift.RevisionID,
|
||||
// A later pool revision may raise the current price; the historical paid
|
||||
// amount remains an entitlement instead of being compared to that price.
|
||||
MsgID: 700002, Date: 1700001004, ConvertStars: 25, PrepaidUpgradeStars: 50,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create prepaid saved gift: %v", err)
|
||||
}
|
||||
prepaid, err := upgrades.UpgradeStarGift(ctx, domain.StarGiftUpgradeRequest{
|
||||
UserID: owner.ID, Ref: domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: 700002},
|
||||
RequirePrepaid: true, CommandKey: "prepaid-" + suffix, Date: 1700001005,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("free prepaid upgrade: %v", err)
|
||||
}
|
||||
if prepaid.Saved.ID != prepaidSavedID || prepaid.Unique.Num != 2 || prepaid.Balance.Balance != 900 ||
|
||||
prepaid.Send.RecipientMessage.Media.ServiceAction.StarGiftUnique == nil ||
|
||||
!prepaid.Send.RecipientMessage.Media.ServiceAction.StarGiftUnique.PrepaidUpgrade {
|
||||
t.Fatalf("prepaid upgrade = %+v", prepaid)
|
||||
}
|
||||
|
||||
insufficientSavedID, err := gifts.Create(ctx, domain.SavedStarGift{
|
||||
Owner: ownerPeer, FromUserID: sender.ID, GiftID: entry.Gift.ID, RevisionID: entry.Gift.RevisionID,
|
||||
MsgID: 700003, Date: 1700001006, ConvertStars: 25,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create insufficient saved gift: %v", err)
|
||||
}
|
||||
if _, err := stars.Debit(ctx, owner.ID, 850, domain.StarsReasonReaction,
|
||||
domain.Peer{Type: domain.PeerTypeChannel, ID: 777001}, 1700001007, "paid reaction", ""); err != nil {
|
||||
t.Fatalf("seed isolated paid reaction debit: %v", err)
|
||||
}
|
||||
if _, err := upgrades.UpgradeStarGift(ctx, domain.StarGiftUpgradeRequest{
|
||||
UserID: owner.ID, Ref: domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: 700003},
|
||||
ChargeStars: 100, CommandKey: "insufficient-" + suffix, Date: 1700001008,
|
||||
}); !errors.Is(err, domain.ErrStarsInsufficient) {
|
||||
t.Fatalf("insufficient upgrade err = %v", err)
|
||||
}
|
||||
insufficientSaved, found, err := gifts.GetByRef(ctx, domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: 700003})
|
||||
if err != nil || !found || insufficientSaved.ID != insufficientSavedID || insufficientSaved.UniqueGiftID != 0 {
|
||||
t.Fatalf("saved gift after rejected upgrade = %+v found %v err %v", insufficientSaved, found, err)
|
||||
}
|
||||
if err := pool.QueryRow(ctx, `SELECT issued FROM star_gift_collectible_revisions WHERE id=$1`, poolRevision.ID).Scan(&issued); err != nil || issued != 2 {
|
||||
t.Fatalf("issued after rejected upgrade = %d err %v, want 2", issued, err)
|
||||
}
|
||||
if err := pool.QueryRow(ctx, `SELECT reason FROM stars_transactions WHERE user_id=$1 ORDER BY id DESC LIMIT 1`, owner.ID).Scan(&reason); err != nil || reason != string(domain.StarsReasonReaction) {
|
||||
t.Fatalf("paid reaction ledger reason after rejected upgrade = %q err %v", reason, err)
|
||||
}
|
||||
|
||||
collection, err := gifts.CreateCollection(ctx, ownerPeer, "Favorites", []int64{savedID})
|
||||
if err != nil {
|
||||
t.Fatalf("create unique collection: %v", err)
|
||||
}
|
||||
filtered, err := gifts.ListByOwnerFiltered(ctx, domain.SavedStarGiftFilter{Owner: ownerPeer, CollectionID: collection.CollectionID, Limit: 10})
|
||||
if err != nil || filtered.Count != 1 || len(filtered.Gifts) != 1 || filtered.Gifts[0].UniqueGiftID != upgraded.Unique.ID {
|
||||
t.Fatalf("collection filter = %+v err %v", filtered, err)
|
||||
}
|
||||
if err := gifts.SetPinned(ctx, ownerPeer, []int64{savedID}); err != nil {
|
||||
t.Fatalf("pin unique gift: %v", err)
|
||||
}
|
||||
pinned, found, err := gifts.GetByRef(ctx, req.Ref)
|
||||
if err != nil || !found || pinned.PinnedOrder != 1 || len(pinned.CollectionIDs) != 1 || pinned.CollectionIDs[0] != collection.CollectionID {
|
||||
t.Fatalf("pinned saved gift = %+v found %v err %v", pinned, found, err)
|
||||
}
|
||||
|
||||
concurrentOwner := createTestUser(t, ctx, users, "+1778"+suffix+"43", "ConcurrentOwner", "")
|
||||
concurrentPeer := domain.Peer{Type: domain.PeerTypeUser, ID: concurrentOwner.ID}
|
||||
if _, err := gifts.Create(ctx, domain.SavedStarGift{
|
||||
Owner: concurrentPeer, FromUserID: sender.ID, GiftID: entry.Gift.ID, RevisionID: entry.Gift.RevisionID,
|
||||
MsgID: 700004, Date: 1700001010, ConvertStars: 25,
|
||||
}); err != nil {
|
||||
t.Fatalf("create concurrent upgrade target: %v", err)
|
||||
}
|
||||
if _, _, err := stars.EnsureGrant(ctx, concurrentOwner.ID, 150, 1700001011); err != nil {
|
||||
t.Fatalf("grant concurrent balance: %v", err)
|
||||
}
|
||||
type concurrentDebitResult struct {
|
||||
kind string
|
||||
err error
|
||||
}
|
||||
start := make(chan struct{})
|
||||
results := make(chan concurrentDebitResult, 2)
|
||||
go func() {
|
||||
<-start
|
||||
_, err := upgrades.UpgradeStarGift(ctx, domain.StarGiftUpgradeRequest{
|
||||
UserID: concurrentOwner.ID, Ref: domain.SavedStarGiftRef{Owner: concurrentPeer, MsgID: 700004},
|
||||
ChargeStars: 100, FormID: 993, CommandKey: "concurrent-upgrade-" + suffix, Date: 1700001012,
|
||||
})
|
||||
results <- concurrentDebitResult{kind: "gift_upgrade", err: err}
|
||||
}()
|
||||
go func() {
|
||||
<-start
|
||||
_, err := stars.Debit(ctx, concurrentOwner.ID, 100, domain.StarsReasonReaction,
|
||||
domain.Peer{Type: domain.PeerTypeChannel, ID: 777002}, 1700001012, "paid reaction", "")
|
||||
results <- concurrentDebitResult{kind: "paid_reaction", err: err}
|
||||
}()
|
||||
close(start)
|
||||
firstResult, secondResult := <-results, <-results
|
||||
successes := 0
|
||||
for _, result := range []concurrentDebitResult{firstResult, secondResult} {
|
||||
if result.err == nil {
|
||||
successes++
|
||||
continue
|
||||
}
|
||||
if !errors.Is(result.err, domain.ErrStarsInsufficient) {
|
||||
t.Fatalf("concurrent %s err = %v, want Stars insufficient for loser", result.kind, result.err)
|
||||
}
|
||||
}
|
||||
if successes != 1 {
|
||||
t.Fatalf("concurrent debit results = %+v / %+v, want exactly one success", firstResult, secondResult)
|
||||
}
|
||||
concurrentBalance, err := stars.GetBalance(ctx, concurrentOwner.ID)
|
||||
if err != nil || concurrentBalance.Balance != 50 {
|
||||
t.Fatalf("concurrent balance = %+v err %v, want 50", concurrentBalance, err)
|
||||
}
|
||||
reasonRows, err := pool.Query(ctx, `SELECT reason FROM stars_transactions WHERE user_id=$1 AND amount<0 ORDER BY id`, concurrentOwner.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("list concurrent debit reasons: %v", err)
|
||||
}
|
||||
var debitReasons []string
|
||||
for reasonRows.Next() {
|
||||
var got string
|
||||
if err := reasonRows.Scan(&got); err != nil {
|
||||
reasonRows.Close()
|
||||
t.Fatal(err)
|
||||
}
|
||||
debitReasons = append(debitReasons, got)
|
||||
}
|
||||
if err := reasonRows.Err(); err != nil {
|
||||
reasonRows.Close()
|
||||
t.Fatal(err)
|
||||
}
|
||||
reasonRows.Close()
|
||||
if len(debitReasons) != 1 || (debitReasons[0] != string(domain.StarsReasonGiftUpgrade) && debitReasons[0] != string(domain.StarsReasonReaction)) {
|
||||
t.Fatalf("concurrent debit reasons = %+v, want exactly one isolated business reason", debitReasons)
|
||||
}
|
||||
|
||||
soldOutEntry, err := gifts.CreateCatalogRevision(ctx, domain.StarGiftCatalogWrite{
|
||||
Title: "Nova", Stars: 25, ConvertStars: 10, Enabled: true,
|
||||
Document: collectibleTestDocument(baseDocumentID+100, "nova.tgs"),
|
||||
Blob: collectibleTestBlob(baseDocumentID+100, "nova"), Animation: collectibleTestAnimation("nova.tgs"),
|
||||
Actor: "integration", CommandID: "soldout-catalog-" + suffix,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create sold-out catalog: %v", err)
|
||||
}
|
||||
soldOutRevision, err := gifts.PublishCollectibleRevision(ctx, domain.StarGiftCollectibleWrite{
|
||||
GiftID: soldOutEntry.Gift.ID, UpgradeStars: 10, SupplyTotal: 1, SlugPrefix: "nova-" + suffix,
|
||||
Models: []domain.StarGiftCollectibleAttribute{{
|
||||
Kind: domain.StarGiftCollectibleModel, Name: "Nova", RarityPermille: 1000,
|
||||
Document: collectibleTestDocumentPtr(baseDocumentID+101, "nova-model.tgs"),
|
||||
Blob: collectibleTestBlobPtr(baseDocumentID+101, "nova-model"), Animation: collectibleTestAnimationPtr("nova-model.tgs"),
|
||||
}},
|
||||
Patterns: []domain.StarGiftCollectibleAttribute{{
|
||||
Kind: domain.StarGiftCollectiblePattern, Name: "Ray", RarityPermille: 1000,
|
||||
Document: collectibleTestDocumentPtr(baseDocumentID+102, "nova-pattern.tgs"),
|
||||
Blob: collectibleTestBlobPtr(baseDocumentID+102, "nova-pattern"), Animation: collectibleTestAnimationPtr("nova-pattern.tgs"),
|
||||
}},
|
||||
Backdrops: []domain.StarGiftCollectibleAttribute{{
|
||||
Kind: domain.StarGiftCollectibleBackdrop, Name: "Void", BackdropID: 2,
|
||||
CenterColor: 0x101010, EdgeColor: 0x202020, PatternColor: 0x303030, TextColor: 0xffffff,
|
||||
RarityPermille: 1000,
|
||||
}},
|
||||
Actor: "integration", CommandID: "soldout-pool-" + suffix,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("publish sold-out pool: %v", err)
|
||||
}
|
||||
soldOutOwner := createTestUser(t, ctx, users, "+1778"+suffix+"44", "SoldOutOwner", "")
|
||||
soldOutPeer := domain.Peer{Type: domain.PeerTypeUser, ID: soldOutOwner.ID}
|
||||
for index, msgID := range []int{700010, 700011} {
|
||||
if _, err := gifts.Create(ctx, domain.SavedStarGift{
|
||||
Owner: soldOutPeer, FromUserID: sender.ID, GiftID: soldOutEntry.Gift.ID, RevisionID: soldOutEntry.Gift.RevisionID,
|
||||
MsgID: msgID, Date: 1700001020 + index, ConvertStars: 10,
|
||||
}); err != nil {
|
||||
t.Fatalf("create sold-out target %d: %v", msgID, err)
|
||||
}
|
||||
}
|
||||
if _, _, err := stars.EnsureGrant(ctx, soldOutOwner.ID, 100, 1700001022); err != nil {
|
||||
t.Fatalf("grant sold-out owner balance: %v", err)
|
||||
}
|
||||
if _, err := upgrades.UpgradeStarGift(ctx, domain.StarGiftUpgradeRequest{
|
||||
UserID: soldOutOwner.ID, Ref: domain.SavedStarGiftRef{Owner: soldOutPeer, MsgID: 700010},
|
||||
ChargeStars: 10, CommandKey: "soldout-first-" + suffix, Date: 1700001023,
|
||||
}); err != nil {
|
||||
t.Fatalf("fill collectible supply: %v", err)
|
||||
}
|
||||
balanceBeforeSoldOut, _ := stars.GetBalance(ctx, soldOutOwner.ID)
|
||||
if _, err := upgrades.UpgradeStarGift(ctx, domain.StarGiftUpgradeRequest{
|
||||
UserID: soldOutOwner.ID, Ref: domain.SavedStarGiftRef{Owner: soldOutPeer, MsgID: 700011},
|
||||
ChargeStars: 10, CommandKey: "soldout-second-" + suffix, Date: 1700001024,
|
||||
}); !errors.Is(err, domain.ErrStarGiftCollectibleSoldOut) {
|
||||
t.Fatalf("sold-out upgrade err = %v", err)
|
||||
}
|
||||
balanceAfterSoldOut, _ := stars.GetBalance(ctx, soldOutOwner.ID)
|
||||
var soldOutIssued int
|
||||
if err := pool.QueryRow(ctx, `SELECT issued FROM star_gift_collectible_revisions WHERE id=$1`, soldOutRevision.ID).Scan(&soldOutIssued); err != nil || soldOutIssued != 1 || balanceAfterSoldOut.Balance != balanceBeforeSoldOut.Balance {
|
||||
t.Fatalf("sold-out state issued=%d balance=%d->%d err=%v", soldOutIssued, balanceBeforeSoldOut.Balance, balanceAfterSoldOut.Balance, err)
|
||||
}
|
||||
|
||||
ordinaryCollection, err := gifts.CreateCollection(ctx, ownerPeer, "Ordinary", []int64{insufficientSavedID})
|
||||
if err != nil {
|
||||
t.Fatalf("create ordinary collection: %v", err)
|
||||
}
|
||||
converted, err := gifts.MarkConverted(ctx, domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: 700003})
|
||||
if err != nil || !converted.Converted || converted.PinnedOrder != 0 || len(converted.CollectionIDs) != 0 {
|
||||
t.Fatalf("convert collection member = %+v err %v", converted, err)
|
||||
}
|
||||
collections, err := gifts.ListCollections(ctx, ownerPeer)
|
||||
if err != nil {
|
||||
t.Fatalf("list collections after conversion: %v", err)
|
||||
}
|
||||
foundOrdinary := false
|
||||
for _, got := range collections {
|
||||
if got.CollectionID != ordinaryCollection.CollectionID {
|
||||
continue
|
||||
}
|
||||
foundOrdinary = true
|
||||
if len(got.GiftIDs) != 0 || got.Hash != domain.StarGiftCollectionHash(got.Title, nil) || got.Hash == ordinaryCollection.Hash {
|
||||
t.Fatalf("ordinary collection after conversion = %+v", got)
|
||||
}
|
||||
}
|
||||
if !foundOrdinary {
|
||||
t.Fatal("ordinary collection disappeared after member conversion")
|
||||
}
|
||||
filteredAfterConvert, err := gifts.ListByOwnerFiltered(ctx, domain.SavedStarGiftFilter{
|
||||
Owner: ownerPeer, CollectionID: ordinaryCollection.CollectionID, Limit: 10,
|
||||
})
|
||||
if err != nil || filteredAfterConvert.Count != 0 || len(filteredAfterConvert.Gifts) != 0 {
|
||||
t.Fatalf("converted collection filter = %+v err %v, want empty", filteredAfterConvert, err)
|
||||
}
|
||||
}
|
||||
|
||||
func collectibleTestAnimation(name string) domain.StarGiftAnimation {
|
||||
return domain.StarGiftAnimation{
|
||||
SourceName: name, SourceFormat: domain.StarGiftAnimationTGS,
|
||||
JSON: []byte(`{"v":"5.7","w":512,"h":512,"fr":30,"ip":0,"op":30,"layers":[{}]}`),
|
||||
TGS: []byte("test"), SHA256: make([]byte, 32), Width: 512, Height: 512, FrameRate: 30, OutPoint: 30,
|
||||
}
|
||||
}
|
||||
|
||||
func collectibleTestAnimationPtr(name string) *domain.StarGiftAnimation {
|
||||
animation := collectibleTestAnimation(name)
|
||||
return &animation
|
||||
}
|
||||
|
||||
func collectibleTestDocument(id int64, name string) domain.Document {
|
||||
return domain.Document{
|
||||
ID: id, AccessHash: id + 100, FileReference: []byte("collectible-test"), Date: 1700001000,
|
||||
MimeType: "application/x-tgsticker", Size: 4, DCID: 2,
|
||||
Attributes: []domain.DocumentAttribute{
|
||||
{Kind: domain.DocAttrImageSize, W: 512, H: 512},
|
||||
{Kind: domain.DocAttrSticker, Alt: "🎁"},
|
||||
{Kind: domain.DocAttrFilename, FileName: name},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func collectibleTestDocumentPtr(id int64, name string) *domain.Document {
|
||||
document := collectibleTestDocument(id, name)
|
||||
return &document
|
||||
}
|
||||
|
||||
func collectibleTestBlob(id int64, suffix string) domain.FileBlob {
|
||||
return domain.FileBlob{
|
||||
LocationKey: fmt.Sprintf("doc:%d", id), Backend: domain.MediaBackendLocalFS,
|
||||
ObjectKey: "collectible-integration-" + suffix, Size: 4, SHA256: make([]byte, 32), MimeType: "application/x-tgsticker",
|
||||
}
|
||||
}
|
||||
|
||||
func collectibleTestBlobPtr(id int64, suffix string) *domain.FileBlob {
|
||||
blob := collectibleTestBlob(id, suffix)
|
||||
return &blob
|
||||
}
|
||||
|
|
@ -3,13 +3,15 @@ package postgres
|
|||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// TestStarGiftStorePostgres 回归迁移 0011:用户收到礼物实例对真实 PG 的 CRUD
|
||||
// (创建 / keyset 分页 / excludeUnsaved / 隐藏切换 / 转换幂等)。
|
||||
// TestStarGiftStorePostgres 回归迁移 0089:目录不可变版本与用户收到礼物实例对真实 PG 的 CRUD
|
||||
// (版本固定 / 创建 / keyset 分页 / excludeUnsaved / 隐藏切换 / 转换幂等)。
|
||||
func TestStarGiftStorePostgres(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
|
|
@ -26,21 +28,72 @@ func TestStarGiftStorePostgres(t *testing.T) {
|
|||
t.Fatalf("create sender: %v", err)
|
||||
}
|
||||
ownerPeer := domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID}
|
||||
docID := time.Now().UnixNano() & 0x7fffffffffffffff
|
||||
documentIDs := []int64{docID}
|
||||
locationKeys := []string{"doc:" + fmt.Sprint(docID)}
|
||||
entry, err := st.CreateCatalogRevision(ctx, domain.StarGiftCatalogWrite{
|
||||
Stars: 50, ConvertStars: 50, Enabled: true, Document: domain.Document{
|
||||
ID: docID, AccessHash: docID + 1, MimeType: "application/x-tgsticker", Size: 4, DCID: 2,
|
||||
Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrSticker}},
|
||||
},
|
||||
Blob: domain.FileBlob{LocationKey: "doc:" + fmt.Sprint(docID), Backend: domain.MediaBackendLocalFS, ObjectKey: "test-star-gift", Size: 4, SHA256: make([]byte, 32), MimeType: "application/x-tgsticker"},
|
||||
Animation: domain.StarGiftAnimation{JSON: []byte(`{"v":"5.7","w":512,"h":512,"fr":30,"ip":0,"op":30,"layers":[{}]}`), SHA256: make([]byte, 32), SourceFormat: domain.StarGiftAnimationTGS, Width: 512, Height: 512},
|
||||
Actor: "test", CommandID: "test-star-gift-" + suffix,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create catalog gift: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(ctx, "DELETE FROM peer_star_gifts WHERE owner_peer_id IN ($1, $2)", owner.ID, int64(987654321))
|
||||
tx, _ := pool.Begin(ctx)
|
||||
if tx != nil {
|
||||
_, _ = tx.Exec(ctx, "DELETE FROM star_gift_catalog WHERE gift_id=$1", entry.Gift.ID)
|
||||
_, _ = tx.Exec(ctx, "DELETE FROM star_gift_catalog_revisions WHERE gift_id=$1", entry.Gift.ID)
|
||||
_, _ = tx.Exec(ctx, "DELETE FROM file_blobs WHERE location_key = ANY($1::text[])", locationKeys)
|
||||
_, _ = tx.Exec(ctx, "DELETE FROM documents WHERE id = ANY($1::bigint[])", documentIDs)
|
||||
_ = tx.Commit(ctx)
|
||||
}
|
||||
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, from.ID})
|
||||
})
|
||||
|
||||
// 创建三份礼物(msg_id 递增)。
|
||||
for i := 0; i < 3; i++ {
|
||||
if _, err := st.Create(ctx, domain.SavedStarGift{
|
||||
Owner: ownerPeer, FromUserID: from.ID, GiftID: 8001, MsgID: 100 + i,
|
||||
Owner: ownerPeer, FromUserID: from.ID, GiftID: entry.Gift.ID, RevisionID: entry.Gift.RevisionID, MsgID: 100 + i,
|
||||
Date: 1700000000 + i, ConvertStars: 50,
|
||||
}); err != nil {
|
||||
t.Fatalf("create gift #%d: %v", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
// active revision 更新后,已收到的礼物必须继续固定到购买瞬间的 immutable revision。
|
||||
docID2 := docID + 1
|
||||
documentIDs = append(documentIDs, docID2)
|
||||
locationKeys = append(locationKeys, "doc:"+fmt.Sprint(docID2))
|
||||
updated, err := st.CreateCatalogRevision(ctx, domain.StarGiftCatalogWrite{
|
||||
GiftID: entry.Gift.ID, Title: "Revision 2", Stars: 75, ConvertStars: 25, Enabled: true,
|
||||
Document: domain.Document{
|
||||
ID: docID2, AccessHash: docID2 + 1, MimeType: "application/x-tgsticker", Size: 4, DCID: 2,
|
||||
Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrSticker}},
|
||||
},
|
||||
Blob: domain.FileBlob{LocationKey: "doc:" + fmt.Sprint(docID2), Backend: domain.MediaBackendLocalFS, ObjectKey: "test-star-gift-v2", Size: 4, SHA256: make([]byte, 32), MimeType: "application/x-tgsticker"},
|
||||
Animation: domain.StarGiftAnimation{JSON: []byte(`{"v":"5.7","w":512,"h":512,"fr":30,"ip":0,"op":30,"layers":[{}]}`), SHA256: make([]byte, 32), SourceFormat: domain.StarGiftAnimationTGS, Width: 512, Height: 512},
|
||||
Actor: "test", CommandID: "test-star-gift-v2-" + suffix,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create catalog revision 2: %v", err)
|
||||
}
|
||||
if updated.Revision != 2 || updated.Gift.RevisionID == entry.Gift.RevisionID {
|
||||
t.Fatalf("revision 2 = %+v, want a new immutable revision", updated)
|
||||
}
|
||||
if updated.ReceivedCount != 3 {
|
||||
t.Fatalf("revision 2 received count = %d, want all 3 historical instances", updated.ReceivedCount)
|
||||
}
|
||||
historical, found, err := st.CatalogRevision(ctx, entry.Gift.RevisionID)
|
||||
if err != nil || !found || historical.Stars != 50 || historical.Sticker.ID != docID {
|
||||
t.Fatalf("historical revision = %+v found %v err %v", historical, found, err)
|
||||
}
|
||||
|
||||
// keyset 分页:每页 2,末页省略游标。
|
||||
page1, err := st.ListByOwner(ctx, ownerPeer, false, "", 2)
|
||||
if err != nil {
|
||||
|
|
@ -71,7 +124,7 @@ func TestStarGiftStorePostgres(t *testing.T) {
|
|||
|
||||
// GetByRef(user msg_id)。
|
||||
g, found, err := st.GetByRef(ctx, domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: 100})
|
||||
if err != nil || !found || g.GiftID != 8001 || g.ConvertStars != 50 {
|
||||
if err != nil || !found || g.GiftID != entry.Gift.ID || g.RevisionID != entry.Gift.RevisionID || g.ConvertStars != 50 {
|
||||
t.Fatalf("get = %+v found %v err %v", g, found, err)
|
||||
}
|
||||
|
||||
|
|
@ -101,13 +154,13 @@ func TestStarGiftStorePostgres(t *testing.T) {
|
|||
// 频道礼物用 saved_id 定位,和用户 msg_id 身份键隔离。
|
||||
channelPeer := domain.Peer{Type: domain.PeerTypeChannel, ID: 987654321}
|
||||
if _, err := st.Create(ctx, domain.SavedStarGift{
|
||||
Owner: ownerPeer, FromUserID: from.ID, GiftID: 8001, MsgID: 700,
|
||||
Owner: ownerPeer, FromUserID: from.ID, GiftID: entry.Gift.ID, RevisionID: entry.Gift.RevisionID, MsgID: 700,
|
||||
Date: 1700000100, ConvertStars: 50,
|
||||
}); err != nil {
|
||||
t.Fatalf("create user gift with same msg_id namespace: %v", err)
|
||||
}
|
||||
channelSavedID, err := st.Create(ctx, domain.SavedStarGift{
|
||||
Owner: channelPeer, FromUserID: from.ID, GiftID: 8001, MsgID: 0, SavedID: 0,
|
||||
Owner: channelPeer, FromUserID: from.ID, GiftID: entry.Gift.ID, RevisionID: entry.Gift.RevisionID, MsgID: 0, SavedID: 0,
|
||||
Date: 1700000101, ConvertStars: 50,
|
||||
})
|
||||
if err != nil {
|
||||
|
|
|
|||
365
internal/store/postgres/star_gift_upgrade.go
Normal file
365
internal/store/postgres/star_gift_upgrade.go
Normal file
|
|
@ -0,0 +1,365 @@
|
|||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"strings"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
"telesrv/internal/store/postgres/sqlcgen"
|
||||
)
|
||||
|
||||
// StarGiftUpgradeStore is the PostgreSQL aggregate coordinator for collectible
|
||||
// upgrades. It intentionally shares MessageStore's allocator and transaction
|
||||
// machinery so Stars, issuance, the saved gift and durable updates commit once.
|
||||
type StarGiftUpgradeStore struct {
|
||||
db sqlcgen.DBTX
|
||||
messages *MessageStore
|
||||
}
|
||||
|
||||
func NewStarGiftUpgradeStore(db sqlcgen.DBTX, messages *MessageStore) *StarGiftUpgradeStore {
|
||||
return &StarGiftUpgradeStore{db: db, messages: messages}
|
||||
}
|
||||
|
||||
func (s *StarGiftUpgradeStore) UpgradeStarGift(ctx context.Context, req domain.StarGiftUpgradeRequest) (domain.StarGiftUpgradeResult, error) {
|
||||
if s == nil || s.db == nil || s.messages == nil || req.UserID <= 0 || !req.Ref.Valid() ||
|
||||
req.Ref.Owner != (domain.Peer{Type: domain.PeerTypeUser, ID: req.UserID}) ||
|
||||
req.ChargeStars < 0 || req.Date <= 0 || strings.TrimSpace(req.CommandKey) == "" || len(req.CommandKey) > 256 {
|
||||
return domain.StarGiftUpgradeResult{}, domain.ErrStarGiftCollectibleInvalid
|
||||
}
|
||||
saved, found, err := NewStarGiftStore(s.db).GetByRef(ctx, req.Ref)
|
||||
if err != nil {
|
||||
return domain.StarGiftUpgradeResult{}, err
|
||||
}
|
||||
if !found || saved.FromUserID <= 0 {
|
||||
return domain.StarGiftUpgradeResult{}, domain.ErrStarGiftNotFound
|
||||
}
|
||||
|
||||
commandKey := strings.TrimSpace(req.CommandKey)
|
||||
fingerprint := sha256.Sum256([]byte(fmt.Sprintf(
|
||||
"telesrv:star-gift-upgrade:v1:%s:%d:%d:%t:%d:%t",
|
||||
commandKey, saved.ID, req.ChargeStars, req.RequirePrepaid, req.FormID, req.KeepOriginalDetails,
|
||||
)))
|
||||
randomID := starGiftUpgradeRandomID(saved.FromUserID, req.UserID, commandKey)
|
||||
placeholder := &domain.MessageMedia{
|
||||
Kind: domain.MessageMediaKindService,
|
||||
ServiceAction: &domain.MessageServiceAction{
|
||||
Kind: domain.MessageServiceActionStarGiftUnique,
|
||||
StarGiftUnique: &domain.MessageStarGiftUniqueAction{Upgrade: true, Saved: true},
|
||||
},
|
||||
}
|
||||
messageReq := domain.SendPrivateTextRequest{
|
||||
SenderUserID: saved.FromUserID,
|
||||
RecipientUserID: req.UserID,
|
||||
RandomID: randomID,
|
||||
Media: placeholder,
|
||||
Date: req.Date,
|
||||
OriginAuthKeyID: req.OriginAuthKeyID,
|
||||
OriginSessionID: req.OriginSessionID,
|
||||
OriginUserID: req.UserID,
|
||||
IdempotencyFingerprint: fingerprint[:],
|
||||
}
|
||||
|
||||
var result domain.StarGiftUpgradeResult
|
||||
hooks := privateSendTxHooks{
|
||||
before: func(ctx context.Context, tx pgx.Tx, messageReq *domain.SendPrivateTextRequest) error {
|
||||
locked, err := lockSavedStarGiftForUpgrade(ctx, tx, req.Ref)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if locked.ID != saved.ID || locked.FromUserID != saved.FromUserID {
|
||||
return domain.ErrStarGiftCollectibleInvalid
|
||||
}
|
||||
if locked.Converted {
|
||||
return domain.ErrStarGiftAlreadyConverted
|
||||
}
|
||||
if locked.UniqueGiftID != 0 {
|
||||
return domain.ErrStarGiftAlreadyUpgraded
|
||||
}
|
||||
|
||||
revision, err := lockActiveCollectibleRevision(ctx, tx, locked.GiftID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if revision.Issued >= revision.SupplyTotal {
|
||||
return domain.ErrStarGiftCollectibleSoldOut
|
||||
}
|
||||
if req.RequirePrepaid {
|
||||
// Prepayment is an entitlement captured at gift purchase time. A
|
||||
// later published revision may change the current price, but must not
|
||||
// retroactively invalidate that already-paid entitlement.
|
||||
if req.ChargeStars != 0 || locked.PrepaidUpgradeStars <= 0 {
|
||||
return domain.ErrStarGiftCollectibleUnavailable
|
||||
}
|
||||
} else if req.ChargeStars != revision.UpgradeStars {
|
||||
return domain.ErrStarGiftCollectibleUnavailable
|
||||
}
|
||||
|
||||
balance, err := debitStarGiftUpgrade(ctx, tx, req.UserID, req.ChargeStars, locked.Owner, req.Date)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
modelID, err := chooseCollectibleAttribute(ctx, tx, "star_gift_collectible_models", revision.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
patternID, err := chooseCollectibleAttribute(ctx, tx, "star_gift_collectible_patterns", revision.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
backdropID, err := chooseCollectibleAttribute(ctx, tx, "star_gift_collectible_backdrops", revision.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
num := revision.Issued + 1
|
||||
var uniqueID int64
|
||||
if err := tx.QueryRow(ctx, `SELECT nextval('unique_star_gift_id_seq')`).Scan(&uniqueID); err != nil {
|
||||
return fmt.Errorf("allocate unique star gift id: %w", err)
|
||||
}
|
||||
var title string
|
||||
if err := tx.QueryRow(ctx, `SELECT title FROM star_gift_catalog_revisions WHERE id=$1`, locked.RevisionID).Scan(&title); err != nil {
|
||||
return fmt.Errorf("load upgrade gift title: %w", err)
|
||||
}
|
||||
slug := fmt.Sprintf("%s-%d", revision.SlugPrefix, num)
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO unique_star_gifts
|
||||
(id, gift_id, collectible_revision_id, source_saved_gift_id, title, slug, num,
|
||||
owner_peer_type, owner_peer_id, model_attribute_id, pattern_attribute_id,
|
||||
backdrop_attribute_id, keep_original_details)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)`,
|
||||
uniqueID, locked.GiftID, revision.ID, locked.ID, title, slug, num,
|
||||
string(locked.Owner.Type), locked.Owner.ID, modelID, patternID, backdropID, req.KeepOriginalDetails); err != nil {
|
||||
return fmt.Errorf("insert unique star gift: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `UPDATE star_gift_collectible_revisions SET issued=issued+1 WHERE id=$1`, revision.ID); err != nil {
|
||||
return fmt.Errorf("increment collectible issuance: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE peer_star_gifts
|
||||
SET unique_gift_id=$2, prepaid_upgrade_stars=0, convert_stars=0
|
||||
WHERE id=$1 AND unique_gift_id IS NULL AND NOT converted`, locked.ID, uniqueID); err != nil {
|
||||
return fmt.Errorf("upgrade saved star gift: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO star_gift_upgrade_commands
|
||||
(user_id, command_key, source_saved_gift_id, form_id, unique_gift_id, balance_after)
|
||||
VALUES ($1,$2,$3,$4,$5,$6)`, req.UserID, commandKey, locked.ID, req.FormID, uniqueID, balance.Balance); err != nil {
|
||||
return fmt.Errorf("insert star gift upgrade command: %w", err)
|
||||
}
|
||||
|
||||
unique, found, err := NewStarGiftStore(tx).UniqueByID(ctx, uniqueID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !found {
|
||||
return fmt.Errorf("new unique star gift %d disappeared", uniqueID)
|
||||
}
|
||||
locked.UniqueGiftID = uniqueID
|
||||
locked.PrepaidUpgradeStars = 0
|
||||
locked.ConvertStars = 0
|
||||
locked.Unique = &unique
|
||||
result.Saved, result.Unique, result.Balance = locked, unique, balance
|
||||
messageReq.Media = &domain.MessageMedia{
|
||||
Kind: domain.MessageMediaKindService,
|
||||
ServiceAction: &domain.MessageServiceAction{
|
||||
Kind: domain.MessageServiceActionStarGiftUnique,
|
||||
StarGiftUnique: &domain.MessageStarGiftUniqueAction{
|
||||
Gift: unique, FromUserID: func() int64 {
|
||||
if locked.NameHidden {
|
||||
return 0
|
||||
}
|
||||
return locked.FromUserID
|
||||
}(), Peer: locked.Owner, Upgrade: true, Saved: !locked.Unsaved,
|
||||
PrepaidUpgrade: req.RequirePrepaid,
|
||||
},
|
||||
},
|
||||
}
|
||||
return nil
|
||||
},
|
||||
after: func(ctx context.Context, tx pgx.Tx, sent domain.SendPrivateTextResult) error {
|
||||
ownerMessageID := sent.RecipientMessage.ID
|
||||
if saved.FromUserID == req.UserID {
|
||||
ownerMessageID = sent.SenderMessage.ID
|
||||
}
|
||||
if ownerMessageID <= 0 {
|
||||
return fmt.Errorf("upgrade service message missing owner box")
|
||||
}
|
||||
tag, err := tx.Exec(ctx, `UPDATE peer_star_gifts SET upgrade_msg_id=$2 WHERE id=$1 AND unique_gift_id=$3`, result.Saved.ID, ownerMessageID, result.Unique.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("save star gift upgrade message id: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() != 1 {
|
||||
return fmt.Errorf("save star gift upgrade message id lost aggregate row")
|
||||
}
|
||||
result.Saved.UpgradeMsgID = ownerMessageID
|
||||
return nil
|
||||
},
|
||||
}
|
||||
sent, err := s.messages.sendPrivateTextWithHooks(ctx, messageReq, hooks)
|
||||
if err != nil {
|
||||
return domain.StarGiftUpgradeResult{}, err
|
||||
}
|
||||
result.Send = sent
|
||||
result.Duplicate = sent.Duplicate
|
||||
if sent.Duplicate {
|
||||
return s.loadUpgradeReplay(ctx, req, saved, sent)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func lockSavedStarGiftForUpgrade(ctx context.Context, tx pgx.Tx, ref domain.SavedStarGiftRef) (domain.SavedStarGift, error) {
|
||||
where, args := savedStarGiftRefWhere(ref)
|
||||
row := tx.QueryRow(ctx, `
|
||||
SELECT p.id, p.owner_peer_type, p.owner_peer_id, p.from_user_id, p.gift_id, p.catalog_revision_id,
|
||||
p.msg_id, p.saved_id, p.gift_date, p.name_hidden, p.unsaved, p.converted, p.convert_stars, p.prepaid_upgrade_stars,
|
||||
p.message, COALESCE(p.unique_gift_id, 0), p.upgrade_msg_id, p.pinned_order,
|
||||
COALESCE((SELECT array_agg(i.collection_id ORDER BY c.sort_order, i.collection_id)
|
||||
FROM star_gift_collection_items i
|
||||
JOIN star_gift_collections c ON c.collection_id=i.collection_id
|
||||
WHERE i.saved_gift_id=p.id), ARRAY[]::integer[])
|
||||
FROM peer_star_gifts p WHERE `+where+` FOR UPDATE`, args...)
|
||||
saved, err := scanSavedStarGift(row)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.SavedStarGift{}, domain.ErrStarGiftNotFound
|
||||
}
|
||||
return saved, err
|
||||
}
|
||||
|
||||
func lockActiveCollectibleRevision(ctx context.Context, tx pgx.Tx, giftID int64) (domain.StarGiftCollectibleRevision, error) {
|
||||
var revision domain.StarGiftCollectibleRevision
|
||||
var status string
|
||||
err := tx.QueryRow(ctx, `
|
||||
SELECT r.id, r.gift_id, r.upgrade_stars, r.supply_total, r.issued, r.slug_prefix, r.status
|
||||
FROM star_gift_catalog c
|
||||
JOIN star_gift_collectible_revisions r ON r.id=c.collectible_revision_id
|
||||
WHERE c.gift_id=$1 FOR UPDATE OF r`, giftID).Scan(
|
||||
&revision.ID, &revision.GiftID, &revision.UpgradeStars, &revision.SupplyTotal,
|
||||
&revision.Issued, &revision.SlugPrefix, &status)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.StarGiftCollectibleRevision{}, domain.ErrStarGiftCollectibleUnavailable
|
||||
}
|
||||
if err != nil {
|
||||
return domain.StarGiftCollectibleRevision{}, fmt.Errorf("lock active collectible revision: %w", err)
|
||||
}
|
||||
if status != "published" {
|
||||
return domain.StarGiftCollectibleRevision{}, domain.ErrStarGiftCollectibleUnavailable
|
||||
}
|
||||
return revision, nil
|
||||
}
|
||||
|
||||
func debitStarGiftUpgrade(ctx context.Context, tx pgx.Tx, userID, amount int64, peer domain.Peer, date int) (domain.StarsBalance, error) {
|
||||
result := domain.StarsBalance{UserID: userID}
|
||||
var balance int64
|
||||
err := tx.QueryRow(ctx, `SELECT balance, granted FROM stars_balances WHERE user_id=$1 FOR UPDATE`, userID).Scan(&balance, &result.Granted)
|
||||
if amount == 0 && errors.Is(err, pgx.ErrNoRows) {
|
||||
return result, nil
|
||||
}
|
||||
if errors.Is(err, pgx.ErrNoRows) || (err == nil && balance < amount) {
|
||||
return domain.StarsBalance{}, domain.ErrStarsInsufficient
|
||||
}
|
||||
if err != nil {
|
||||
return domain.StarsBalance{}, fmt.Errorf("lock stars balance for gift upgrade: %w", err)
|
||||
}
|
||||
if amount == 0 {
|
||||
result.Balance = balance
|
||||
return result, nil
|
||||
}
|
||||
if err := tx.QueryRow(ctx, `UPDATE stars_balances SET balance=balance-$2, updated_at=now() WHERE user_id=$1 RETURNING balance`, userID, amount).Scan(&result.Balance); err != nil {
|
||||
return domain.StarsBalance{}, fmt.Errorf("debit star gift upgrade: %w", err)
|
||||
}
|
||||
if err := insertStarsTxn(ctx, tx, userID, -amount, domain.StarsReasonGiftUpgrade, peer, date, "Star gift upgrade", ""); err != nil {
|
||||
return domain.StarsBalance{}, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func chooseCollectibleAttribute(ctx context.Context, tx pgx.Tx, table string, revisionID int64) (int64, error) {
|
||||
rows, err := tx.Query(ctx, fmt.Sprintf(`SELECT id, rarity_permille FROM %s WHERE collectible_revision_id=$1 ORDER BY sort_order, id`, table), revisionID)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("list collectible attributes for issuance: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
type weightedID struct {
|
||||
id int64
|
||||
weight int
|
||||
}
|
||||
items := make([]weightedID, 0)
|
||||
total := 0
|
||||
for rows.Next() {
|
||||
var item weightedID
|
||||
if err := rows.Scan(&item.id, &item.weight); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
items = append(items, item)
|
||||
total += item.weight
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if len(items) == 0 || total != 1000 {
|
||||
return 0, domain.ErrStarGiftCollectibleInvalid
|
||||
}
|
||||
draw, err := rand.Int(rand.Reader, big.NewInt(int64(total)))
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("draw collectible attribute: %w", err)
|
||||
}
|
||||
value := int(draw.Int64())
|
||||
for _, item := range items {
|
||||
if value < item.weight {
|
||||
return item.id, nil
|
||||
}
|
||||
value -= item.weight
|
||||
}
|
||||
return 0, domain.ErrStarGiftCollectibleInvalid
|
||||
}
|
||||
|
||||
func starGiftUpgradeRandomID(senderID, ownerID int64, commandKey string) int64 {
|
||||
sum := sha256.Sum256([]byte(fmt.Sprintf("%d:%d:%s", senderID, ownerID, commandKey)))
|
||||
id := int64(binary.LittleEndian.Uint64(sum[:8]) & 0x7fffffffffffffff)
|
||||
if id == 0 {
|
||||
id = 1
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func (s *StarGiftUpgradeStore) loadUpgradeReplay(ctx context.Context, req domain.StarGiftUpgradeRequest, original domain.SavedStarGift, sent domain.SendPrivateTextResult) (domain.StarGiftUpgradeResult, error) {
|
||||
saved, found, err := NewStarGiftStore(s.db).GetByRef(ctx, req.Ref)
|
||||
if err != nil || !found || saved.UniqueGiftID == 0 {
|
||||
if err == nil {
|
||||
err = domain.ErrStarGiftCollectibleInvalid
|
||||
}
|
||||
return domain.StarGiftUpgradeResult{}, err
|
||||
}
|
||||
unique, found, err := NewStarGiftStore(s.db).UniqueByID(ctx, saved.UniqueGiftID)
|
||||
if err != nil || !found {
|
||||
if err == nil {
|
||||
err = domain.ErrStarGiftCollectibleInvalid
|
||||
}
|
||||
return domain.StarGiftUpgradeResult{}, err
|
||||
}
|
||||
var commandUniqueID int64
|
||||
var balanceAfter int64
|
||||
if err := s.db.QueryRow(ctx, `SELECT unique_gift_id, balance_after FROM star_gift_upgrade_commands WHERE user_id=$1 AND command_key=$2`, req.UserID, strings.TrimSpace(req.CommandKey)).Scan(&commandUniqueID, &balanceAfter); err != nil {
|
||||
return domain.StarGiftUpgradeResult{}, fmt.Errorf("load star gift upgrade replay: %w", err)
|
||||
}
|
||||
if commandUniqueID != unique.ID || saved.ID != original.ID {
|
||||
return domain.StarGiftUpgradeResult{}, domain.ErrStarGiftCollectibleInvalid
|
||||
}
|
||||
uniqueCopy := unique
|
||||
saved.Unique = &uniqueCopy
|
||||
return domain.StarGiftUpgradeResult{
|
||||
Saved: saved, Unique: unique, Balance: domain.StarsBalance{UserID: req.UserID, Balance: balanceAfter},
|
||||
Send: sent, Duplicate: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
var _ store.StarGiftUpgradeStore = (*StarGiftUpgradeStore)(nil)
|
||||
|
|
@ -4,43 +4,161 @@ import (
|
|||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
"telesrv/internal/store/postgres/sqlcgen"
|
||||
)
|
||||
|
||||
// TempAuthKeyBindingStore 用 PostgreSQL 实现 store.TempAuthKeyBindingStore。
|
||||
type TempAuthKeyBindingStore struct {
|
||||
q *sqlcgen.Queries
|
||||
db sqlcgen.DBTX
|
||||
q *sqlcgen.Queries
|
||||
}
|
||||
|
||||
// NewTempAuthKeyBindingStore 基于 pgx 连接池(或事务)创建 TempAuthKeyBindingStore。
|
||||
func NewTempAuthKeyBindingStore(db sqlcgen.DBTX) *TempAuthKeyBindingStore {
|
||||
return &TempAuthKeyBindingStore{q: sqlcgen.New(db)}
|
||||
return &TempAuthKeyBindingStore{db: db, q: sqlcgen.New(db)}
|
||||
}
|
||||
|
||||
func (s *TempAuthKeyBindingStore) Save(ctx context.Context, b domain.TempAuthKeyBinding) error {
|
||||
if err := s.q.UpsertTempAuthKeyBinding(ctx, sqlcgen.UpsertTempAuthKeyBindingParams{
|
||||
if b.ExpiresAt <= 0 || int64(b.ExpiresAt) > math.MaxInt32 {
|
||||
return store.ErrAuthKeyBindingInvalid
|
||||
}
|
||||
return withAuthIdentityTx(ctx, s.db, "save temp auth key binding", func(tx pgx.Tx) error {
|
||||
return s.saveTx(ctx, tx, b)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *TempAuthKeyBindingStore) saveTx(ctx context.Context, tx pgx.Tx, b domain.TempAuthKeyBinding) error {
|
||||
rawID := authKeyIDToInt64(b.TempAuthKeyID)
|
||||
permID := b.PermAuthKeyID
|
||||
// Every operation that may bridge temp and permanent rows enters the
|
||||
// permanent identity gate before taking the raw-key row lock. This is the
|
||||
// same gate/order used by selector advance and permanent revocation.
|
||||
if err := lockPermanentAuthIdentities(ctx, tx, []int64{permID}); err != nil {
|
||||
return err
|
||||
}
|
||||
var (
|
||||
tempExpiry int
|
||||
tempLayer int
|
||||
tempObservationID int64
|
||||
)
|
||||
if err := tx.QueryRow(ctx, `
|
||||
SELECT expires_at, layer, layer_observation_id
|
||||
FROM auth_keys
|
||||
WHERE auth_key_id = $1
|
||||
FOR UPDATE
|
||||
`, rawID).Scan(&tempExpiry, &tempLayer, &tempObservationID); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return store.ErrAuthKeyBindingInvalid
|
||||
}
|
||||
return fmt.Errorf("lock temporary auth key for binding: %w", err)
|
||||
}
|
||||
if tempExpiry <= 0 || tempExpiry != b.ExpiresAt || rawID == permID {
|
||||
return store.ErrAuthKeyBindingInvalid
|
||||
}
|
||||
|
||||
// The raw row serializes first bind and rebind attempts. Read the binding
|
||||
// only after taking that lock, so a concurrent winner is either visible or
|
||||
// still waiting behind us. A different permanent identity is immutable.
|
||||
var currentPermID int64
|
||||
err := tx.QueryRow(ctx, `
|
||||
SELECT perm_auth_key_id
|
||||
FROM temp_auth_key_bindings
|
||||
WHERE temp_auth_key_id = $1
|
||||
`, rawID).Scan(¤tPermID)
|
||||
switch {
|
||||
case err == nil && currentPermID != permID:
|
||||
return store.ErrTempAuthKeyAlreadyBound
|
||||
case err != nil && !errors.Is(err, pgx.ErrNoRows):
|
||||
return fmt.Errorf("read existing temporary auth key binding: %w", err)
|
||||
}
|
||||
|
||||
var (
|
||||
permExpiry int
|
||||
permLayer int
|
||||
permObservationID int64
|
||||
)
|
||||
if err := tx.QueryRow(ctx, `
|
||||
SELECT expires_at, layer, layer_observation_id
|
||||
FROM auth_keys
|
||||
WHERE auth_key_id = $1
|
||||
FOR UPDATE
|
||||
`, permID).Scan(&permExpiry, &permLayer, &permObservationID); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return store.ErrAuthKeyBindingInvalid
|
||||
}
|
||||
return fmt.Errorf("lock permanent auth key for binding: %w", err)
|
||||
}
|
||||
if permExpiry != 0 {
|
||||
return store.ErrAuthKeyBindingInvalid
|
||||
}
|
||||
|
||||
mergedLayer, mergedObservationID, err := store.MergeAuthKeyLayerObservations(
|
||||
tempLayer, tempObservationID,
|
||||
permLayer, permObservationID,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
q := s.q.WithTx(tx)
|
||||
n, err := q.UpsertTempAuthKeyBinding(ctx, sqlcgen.UpsertTempAuthKeyBindingParams{
|
||||
TempAuthKeyID: authKeyIDToInt64(b.TempAuthKeyID),
|
||||
PermAuthKeyID: b.PermAuthKeyID,
|
||||
Nonce: b.Nonce,
|
||||
TempSessionID: b.TempSessionID,
|
||||
ExpiresAt: int32(b.ExpiresAt),
|
||||
EncryptedMessage: b.EncryptedMessage,
|
||||
}); err != nil {
|
||||
})
|
||||
if err != nil {
|
||||
var pgErr *pgconn.PgError
|
||||
if errors.As(err, &pgErr) && pgErr.Code == "23503" {
|
||||
return store.ErrAuthKeyBindingInvalid
|
||||
}
|
||||
return fmt.Errorf("upsert temp auth key binding: %w", err)
|
||||
}
|
||||
if n == 0 {
|
||||
return store.ErrAuthKeyBindingInvalid
|
||||
}
|
||||
keyIDs := []int64{rawID, permID}
|
||||
tag, err := tx.Exec(ctx, `
|
||||
UPDATE auth_keys
|
||||
SET layer = $2,
|
||||
layer_observation_id = $3
|
||||
WHERE auth_key_id = ANY($1::bigint[])
|
||||
`, keyIDs, mergedLayer, mergedObservationID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("merge bound auth key layer defaults: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() != int64(len(keyIDs)) {
|
||||
return fmt.Errorf("merge bound auth key layer defaults: updated %d of %d locked keys", tag.RowsAffected(), len(keyIDs))
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE authorizations
|
||||
SET layer = $2
|
||||
WHERE auth_key_id = ANY($1::bigint[])
|
||||
`, keyIDs, mergedLayer); err != nil {
|
||||
return fmt.Errorf("mirror bound auth key layer default: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteExpired 实现 store.TempAuthKeyBindingStore:删除 auth_keys 中过期的 temp key,
|
||||
// temp_auth_key_bindings 经 ON DELETE CASCADE 一并清除,过期 key 的入站帧随之失效。
|
||||
// DeleteExpired 实现 store.TempAuthKeyBindingStore:按 auth_keys.expires_at 的部分索引
|
||||
// 有界删除所有过期 temp key(含从未绑定的握手 key),binding 经 CASCADE 一并清除。
|
||||
// Edge 已在准确协议时刻停止使用 key;这里的 24h 宽限只控制数据库物理回收。
|
||||
func (s *TempAuthKeyBindingStore) DeleteExpired(ctx context.Context, expiredBefore int64, limit int) (int, error) {
|
||||
if limit <= 0 {
|
||||
return 0, nil
|
||||
}
|
||||
if expiredBefore <= 0 || expiredBefore > math.MaxInt32 {
|
||||
return 0, fmt.Errorf("delete expired temp auth keys: invalid expiry cutoff %d", expiredBefore)
|
||||
}
|
||||
n, err := s.q.DeleteExpiredTempAuthKeys(ctx, sqlcgen.DeleteExpiredTempAuthKeysParams{
|
||||
ExpiresAt: int32(expiredBefore),
|
||||
Limit: int32(limit),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,556 @@
|
|||
package postgres
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
func TestTempAuthKeyBindingStoreRejectsIntegerWraparoundPostgres(t *testing.T) {
|
||||
if strconv.IntSize < 64 {
|
||||
t.Skip("64-bit int required to construct an out-of-int32 expiry")
|
||||
}
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
keys := NewAuthKeyStore(pool)
|
||||
bindings := NewTempAuthKeyBindingStore(pool)
|
||||
handshakeExpiry := int(time.Now().Add(time.Hour).Unix())
|
||||
temp := saveTempIdentityTestAuthKey(t, ctx, pool, keys, handshakeExpiry)
|
||||
perm := saveTempIdentityTestAuthKey(t, ctx, pool, keys, 0)
|
||||
|
||||
overflow := domain.TempAuthKeyBinding{
|
||||
TempAuthKeyID: temp,
|
||||
PermAuthKeyID: authKeyIDToInt64(perm),
|
||||
Nonce: 901,
|
||||
TempSessionID: 902,
|
||||
ExpiresAt: int(int64(handshakeExpiry) + (int64(1) << 32)),
|
||||
EncryptedMessage: []byte("wraparound"),
|
||||
}
|
||||
if err := bindings.Save(ctx, overflow); !errors.Is(err, store.ErrAuthKeyBindingInvalid) {
|
||||
t.Fatalf("overflow binding expiry error = %v, want %v", err, store.ErrAuthKeyBindingInvalid)
|
||||
}
|
||||
if _, found, err := bindings.GetByTemp(ctx, temp); err != nil || found {
|
||||
t.Fatalf("binding after overflow found=%v err=%v, want absent", found, err)
|
||||
}
|
||||
if _, err := bindings.DeleteExpired(ctx, int64(math.MaxInt32)+1, 1); err == nil {
|
||||
t.Fatal("overflow retention cutoff succeeded, want explicit rejection")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthorizationStoreRejectsTemporaryProtocolKeyPostgres(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
keys := NewAuthKeyStore(pool)
|
||||
temp := saveTempIdentityTestAuthKey(t, ctx, pool, keys, int(time.Now().Add(time.Hour).Unix()))
|
||||
phone := fmt.Sprintf("15558%015d", time.Now().UnixNano())
|
||||
user, err := NewUserStore(pool).Create(ctx, domain.User{Phone: phone, FirstName: "TempKeyGuard"})
|
||||
if err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = $1", user.ID)
|
||||
})
|
||||
|
||||
err = NewAuthorizationStore(pool).Bind(ctx, domain.Authorization{AuthKeyID: temp, UserID: user.ID})
|
||||
if !errors.Is(err, store.ErrAuthKeyNotPermanent) {
|
||||
t.Fatalf("bind authorization to temp key error = %v, want %v", err, store.ErrAuthKeyNotPermanent)
|
||||
}
|
||||
if _, found, getErr := NewAuthorizationStore(pool).ByAuthKey(ctx, temp); getErr != nil || found {
|
||||
t.Fatalf("temporary authorization found=%v err=%v, want absent", found, getErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTempAuthKeyBindingStorePreservesHandshakeExpiryAndRejectsRebindPostgres(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
keys := NewAuthKeyStore(pool)
|
||||
bindings := NewTempAuthKeyBindingStore(pool)
|
||||
|
||||
handshakeExpiry := int(time.Now().Add(time.Hour).Unix())
|
||||
temp := saveTempIdentityTestAuthKey(t, ctx, pool, keys, handshakeExpiry)
|
||||
permA := saveTempIdentityTestAuthKey(t, ctx, pool, keys, 0)
|
||||
permB := saveTempIdentityTestAuthKey(t, ctx, pool, keys, 0)
|
||||
|
||||
first := domain.TempAuthKeyBinding{
|
||||
TempAuthKeyID: temp,
|
||||
PermAuthKeyID: authKeyIDToInt64(permA),
|
||||
Nonce: 101,
|
||||
TempSessionID: 201,
|
||||
ExpiresAt: handshakeExpiry,
|
||||
EncryptedMessage: []byte("first binding"),
|
||||
}
|
||||
if err := bindings.Save(ctx, first); err != nil {
|
||||
t.Fatalf("save first binding: %v", err)
|
||||
}
|
||||
assertTempIdentityBinding(t, ctx, bindings, first)
|
||||
assertTempIdentityAuthKeyExpiry(t, ctx, keys, temp, handshakeExpiry)
|
||||
|
||||
// The app service accepts client-specific proof expiry (TDesktop adds 30s)
|
||||
// but must normalize what it passes to the store. A direct caller cannot
|
||||
// persist proof metadata whose lifetime differs from the handshake key.
|
||||
mismatched := first
|
||||
mismatched.Nonce = 102
|
||||
mismatched.TempSessionID = 202
|
||||
mismatched.ExpiresAt = handshakeExpiry + 60
|
||||
mismatched.EncryptedMessage = []byte("mismatched replay")
|
||||
if err := bindings.Save(ctx, mismatched); !errors.Is(err, store.ErrAuthKeyBindingInvalid) {
|
||||
t.Fatalf("mismatched replay error = %v, want %v", err, store.ErrAuthKeyBindingInvalid)
|
||||
}
|
||||
assertTempIdentityBinding(t, ctx, bindings, first)
|
||||
|
||||
replayed := first
|
||||
replayed.Nonce = 103
|
||||
replayed.TempSessionID = 203
|
||||
replayed.EncryptedMessage = []byte("normalized replay")
|
||||
if err := bindings.Save(ctx, replayed); err != nil {
|
||||
t.Fatalf("replay normalized binding: %v", err)
|
||||
}
|
||||
assertTempIdentityBinding(t, ctx, bindings, replayed)
|
||||
assertTempIdentityAuthKeyExpiry(t, ctx, keys, temp, handshakeExpiry)
|
||||
|
||||
forbidden := replayed
|
||||
forbidden.PermAuthKeyID = authKeyIDToInt64(permB)
|
||||
forbidden.Nonce = 999
|
||||
forbidden.ExpiresAt = handshakeExpiry
|
||||
forbidden.EncryptedMessage = []byte("must not persist")
|
||||
if err := bindings.Save(ctx, forbidden); !errors.Is(err, store.ErrTempAuthKeyAlreadyBound) {
|
||||
t.Fatalf("cross-permanent rebind error = %v, want %v", err, store.ErrTempAuthKeyAlreadyBound)
|
||||
}
|
||||
assertTempIdentityBinding(t, ctx, bindings, replayed)
|
||||
assertTempIdentityAuthKeyExpiry(t, ctx, keys, temp, handshakeExpiry)
|
||||
}
|
||||
|
||||
func TestTempAuthKeyBindingStoreAtomicallyMergesLayerObservationsPostgres(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
tests := []struct {
|
||||
name string
|
||||
tempLayer int
|
||||
tempObs int64
|
||||
permLayer int
|
||||
permObs int64
|
||||
wantLayer int
|
||||
wantObs int64
|
||||
wantErr error
|
||||
}{
|
||||
{name: "temporary newer", tempLayer: 227, tempObs: 20, permLayer: 220, permObs: 10, wantLayer: 227, wantObs: 20},
|
||||
{name: "permanent newer", tempLayer: 220, tempObs: 10, permLayer: 227, permObs: 20, wantLayer: 227, wantObs: 20},
|
||||
{name: "equal ordered same layer", tempLayer: 225, tempObs: 30, permLayer: 225, permObs: 30, wantLayer: 225, wantObs: 30},
|
||||
{name: "equal ordered conflict", tempLayer: 220, tempObs: 30, permLayer: 227, permObs: 30, wantErr: store.ErrAuthKeySessionLayerConflict},
|
||||
{name: "legacy permanent wins", tempLayer: 220, permLayer: 227, wantLayer: 227},
|
||||
}
|
||||
for i, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
userID := createRevokeTestUser(t, ctx, pool, fmt.Sprintf("layer-merge-%d", i))
|
||||
keys := NewAuthKeyStore(pool)
|
||||
bindings := NewTempAuthKeyBindingStore(pool)
|
||||
auths := NewAuthorizationStore(pool)
|
||||
handshakeExpiry := int(time.Now().Add(time.Hour).Unix()) + i
|
||||
tempID := saveTempIdentityTestAuthKey(t, ctx, pool, keys, handshakeExpiry)
|
||||
permID := saveTempIdentityTestAuthKey(t, ctx, pool, keys, 0)
|
||||
if err := auths.Bind(ctx, domain.Authorization{
|
||||
AuthKeyID: permID, UserID: userID, Layer: tt.permLayer,
|
||||
}); err != nil {
|
||||
t.Fatalf("bind permanent authorization: %v", err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, `
|
||||
UPDATE auth_keys SET layer = $2, layer_observation_id = $3
|
||||
WHERE auth_key_id = $1`, authKeyIDToInt64(tempID), tt.tempLayer, tt.tempObs); err != nil {
|
||||
t.Fatalf("seed temporary layer observation: %v", err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, `
|
||||
UPDATE auth_keys SET layer = $2, layer_observation_id = $3
|
||||
WHERE auth_key_id = $1`, authKeyIDToInt64(permID), tt.permLayer, tt.permObs); err != nil {
|
||||
t.Fatalf("seed permanent layer observation: %v", err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, `
|
||||
UPDATE authorizations SET layer = $2 WHERE auth_key_id = $1`, authKeyIDToInt64(permID), tt.permLayer); err != nil {
|
||||
t.Fatalf("seed authorization layer mirror: %v", err)
|
||||
}
|
||||
|
||||
binding := domain.TempAuthKeyBinding{
|
||||
TempAuthKeyID: tempID,
|
||||
PermAuthKeyID: authKeyIDToInt64(permID),
|
||||
Nonce: int64(1_000 + i),
|
||||
TempSessionID: int64(2_000 + i),
|
||||
ExpiresAt: handshakeExpiry,
|
||||
EncryptedMessage: []byte("layer merge proof"),
|
||||
}
|
||||
err := bindings.Save(ctx, binding)
|
||||
if tt.wantErr != nil {
|
||||
if !errors.Is(err, tt.wantErr) {
|
||||
t.Fatalf("bind error = %v, want %v", err, tt.wantErr)
|
||||
}
|
||||
if _, found, getErr := bindings.GetByTemp(ctx, tempID); getErr != nil || found {
|
||||
t.Fatalf("conflicting binding found=%v err=%v, want absent", found, getErr)
|
||||
}
|
||||
assertTempIdentityLayerTuple(t, ctx, pool, tempID, tt.tempLayer, tt.tempObs)
|
||||
assertTempIdentityLayerTuple(t, ctx, pool, permID, tt.permLayer, tt.permObs)
|
||||
assertTempIdentityAuthorizationLayer(t, ctx, pool, permID, tt.permLayer)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("bind: %v", err)
|
||||
}
|
||||
// A normalized proof replay is idempotent and repeats the same merge.
|
||||
binding.Nonce++
|
||||
if err := bindings.Save(ctx, binding); err != nil {
|
||||
t.Fatalf("replay merged binding: %v", err)
|
||||
}
|
||||
assertTempIdentityLayerTuple(t, ctx, pool, tempID, tt.wantLayer, tt.wantObs)
|
||||
assertTempIdentityLayerTuple(t, ctx, pool, permID, tt.wantLayer, tt.wantObs)
|
||||
assertTempIdentityAuthorizationLayer(t, ctx, pool, permID, tt.wantLayer)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTempAuthKeyBindingStoreConcurrentFirstBindKeepsHandshakeExpiryPostgres(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
keys := NewAuthKeyStore(pool)
|
||||
bindings := NewTempAuthKeyBindingStore(pool)
|
||||
|
||||
// A temp key has exactly one permanent identity even when two valid bind
|
||||
// proofs race. Repeat with fresh rows so the test covers the contended first
|
||||
// bind path instead of only the already-bound fast path.
|
||||
for attempt := 0; attempt < 16; attempt++ {
|
||||
handshakeExpiry := int(time.Now().Add(30*time.Minute).Unix()) + attempt
|
||||
temp := saveTempIdentityTestAuthKey(t, ctx, pool, keys, handshakeExpiry)
|
||||
permA := saveTempIdentityTestAuthKey(t, ctx, pool, keys, 0)
|
||||
permB := saveTempIdentityTestAuthKey(t, ctx, pool, keys, 0)
|
||||
candidates := []domain.TempAuthKeyBinding{
|
||||
{
|
||||
TempAuthKeyID: temp, PermAuthKeyID: authKeyIDToInt64(permA), Nonce: 301,
|
||||
ExpiresAt: handshakeExpiry, EncryptedMessage: []byte("candidate-a"),
|
||||
},
|
||||
{
|
||||
TempAuthKeyID: temp, PermAuthKeyID: authKeyIDToInt64(permB), Nonce: 302,
|
||||
ExpiresAt: handshakeExpiry, EncryptedMessage: []byte("candidate-b"),
|
||||
},
|
||||
}
|
||||
|
||||
start := make(chan struct{})
|
||||
results := make(chan error, len(candidates))
|
||||
for _, candidate := range candidates {
|
||||
candidate := candidate
|
||||
go func() {
|
||||
<-start
|
||||
results <- bindings.Save(ctx, candidate)
|
||||
}()
|
||||
}
|
||||
close(start)
|
||||
|
||||
var success, rejected int
|
||||
for range candidates {
|
||||
err := <-results
|
||||
switch {
|
||||
case err == nil:
|
||||
success++
|
||||
case errors.Is(err, store.ErrTempAuthKeyAlreadyBound):
|
||||
rejected++
|
||||
default:
|
||||
t.Fatalf("attempt %d concurrent bind: unexpected error %v", attempt, err)
|
||||
}
|
||||
}
|
||||
if success != 1 || rejected != 1 {
|
||||
t.Fatalf("attempt %d concurrent bind outcomes: success=%d rejected=%d, want 1/1", attempt, success, rejected)
|
||||
}
|
||||
|
||||
got, found, err := bindings.GetByTemp(ctx, temp)
|
||||
if err != nil || !found {
|
||||
t.Fatalf("attempt %d get winner: found=%v err=%v", attempt, found, err)
|
||||
}
|
||||
var winner domain.TempAuthKeyBinding
|
||||
switch got.PermAuthKeyID {
|
||||
case candidates[0].PermAuthKeyID:
|
||||
winner = candidates[0]
|
||||
case candidates[1].PermAuthKeyID:
|
||||
winner = candidates[1]
|
||||
default:
|
||||
t.Fatalf("attempt %d winner perm auth key = %d, want one of the candidates", attempt, got.PermAuthKeyID)
|
||||
}
|
||||
assertTempIdentityBinding(t, ctx, bindings, winner)
|
||||
assertTempIdentityAuthKeyExpiry(t, ctx, keys, temp, handshakeExpiry)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTempAuthKeyBindingStoreRejectsMissingPermanentKeyPostgres(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
keys := NewAuthKeyStore(pool)
|
||||
bindings := NewTempAuthKeyBindingStore(pool)
|
||||
|
||||
handshakeExpiry := int(time.Now().Add(time.Hour).Unix())
|
||||
temp := saveTempIdentityTestAuthKey(t, ctx, pool, keys, handshakeExpiry)
|
||||
missingPerm := randomTempIdentityAuthKeyID(t)
|
||||
candidate := domain.TempAuthKeyBinding{
|
||||
TempAuthKeyID: temp,
|
||||
PermAuthKeyID: authKeyIDToInt64(missingPerm),
|
||||
Nonce: 401,
|
||||
TempSessionID: 402,
|
||||
ExpiresAt: handshakeExpiry,
|
||||
EncryptedMessage: []byte("missing permanent key"),
|
||||
}
|
||||
if err := bindings.Save(ctx, candidate); !errors.Is(err, store.ErrAuthKeyBindingInvalid) {
|
||||
t.Fatalf("missing permanent key error = %v, want %v", err, store.ErrAuthKeyBindingInvalid)
|
||||
}
|
||||
_, rawInsertErr := pool.Exec(ctx, `
|
||||
INSERT INTO temp_auth_key_bindings (
|
||||
temp_auth_key_id, perm_auth_key_id, nonce, temp_session_id, expires_at, encrypted_message
|
||||
) VALUES ($1, $2, $3, $4, $5, $6)`,
|
||||
authKeyIDToInt64(temp), candidate.PermAuthKeyID, candidate.Nonce,
|
||||
candidate.TempSessionID, candidate.ExpiresAt, candidate.EncryptedMessage,
|
||||
)
|
||||
var pgErr *pgconn.PgError
|
||||
if !errors.As(rawInsertErr, &pgErr) || pgErr.Code != "23503" || pgErr.ConstraintName != tempAuthKeyPermFKConstraint {
|
||||
t.Fatalf("raw missing-perm FK error = %v, want 23503/%s", rawInsertErr, tempAuthKeyPermFKConstraint)
|
||||
}
|
||||
if _, found, err := bindings.GetByTemp(ctx, temp); err != nil || found {
|
||||
t.Fatalf("binding with missing permanent key found=%v err=%v, want absent", found, err)
|
||||
}
|
||||
assertTempIdentityAuthKeyExpiry(t, ctx, keys, temp, handshakeExpiry)
|
||||
}
|
||||
|
||||
func TestTempAuthKeyBindingConcurrentWithPermanentDeleteLeavesNoDanglingStatePostgres(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
keys := NewAuthKeyStore(pool)
|
||||
bindings := NewTempAuthKeyBindingStore(pool)
|
||||
|
||||
for attempt := 0; attempt < 32; attempt++ {
|
||||
handshakeExpiry := int(time.Now().Add(time.Hour).Unix()) + attempt
|
||||
temp := saveTempIdentityTestAuthKey(t, ctx, pool, keys, handshakeExpiry)
|
||||
perm := saveTempIdentityTestAuthKey(t, ctx, pool, keys, 0)
|
||||
candidate := domain.TempAuthKeyBinding{
|
||||
TempAuthKeyID: temp,
|
||||
PermAuthKeyID: authKeyIDToInt64(perm),
|
||||
Nonce: int64(500 + attempt),
|
||||
TempSessionID: int64(600 + attempt),
|
||||
ExpiresAt: handshakeExpiry,
|
||||
EncryptedMessage: []byte("bind-delete race"),
|
||||
}
|
||||
|
||||
start := make(chan struct{})
|
||||
bindResult := make(chan error, 1)
|
||||
deleteResult := make(chan error, 1)
|
||||
go func() {
|
||||
<-start
|
||||
bindResult <- bindings.Save(ctx, candidate)
|
||||
}()
|
||||
go func() {
|
||||
<-start
|
||||
deleteResult <- keys.Delete(ctx, perm)
|
||||
}()
|
||||
close(start)
|
||||
|
||||
bindErr := <-bindResult
|
||||
if bindErr != nil && !errors.Is(bindErr, store.ErrAuthKeyBindingInvalid) {
|
||||
t.Fatalf("attempt %d bind/delete race bind error = %v", attempt, bindErr)
|
||||
}
|
||||
if err := <-deleteResult; err != nil {
|
||||
t.Fatalf("attempt %d bind/delete race delete: %v", attempt, err)
|
||||
}
|
||||
if _, found, err := bindings.GetByTemp(ctx, temp); err != nil || found {
|
||||
t.Fatalf("attempt %d dangling binding found=%v err=%v", attempt, found, err)
|
||||
}
|
||||
assertTempIdentityAuthKeyMissing(t, ctx, keys, perm)
|
||||
if bindErr == nil {
|
||||
// The binding committed first, so permanent-key deletion must have
|
||||
// observed it (or retried after the FK race) and deleted the temp key.
|
||||
assertTempIdentityAuthKeyMissing(t, ctx, keys, temp)
|
||||
} else {
|
||||
// Deletion won before the binding existed. The loser remains a valid,
|
||||
// unbound protocol temp key until its own expiry collector runs; it is
|
||||
// not allowed to acquire a binding or authorization to the deleted perm.
|
||||
assertTempIdentityAuthKeyExpiry(t, ctx, keys, temp, handshakeExpiry)
|
||||
if _, found, err := NewAuthorizationStore(pool).ByAuthKey(ctx, temp); err != nil || found {
|
||||
t.Fatalf("attempt %d loser temp authorization found=%v err=%v", attempt, found, err)
|
||||
}
|
||||
if err := keys.Delete(ctx, temp); err != nil {
|
||||
t.Fatalf("attempt %d clean unbound loser temp: %v", attempt, err)
|
||||
}
|
||||
assertTempIdentityAuthKeyMissing(t, ctx, keys, temp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func waitForPostgresBackendLockWait(
|
||||
t *testing.T,
|
||||
ctx context.Context,
|
||||
pool *pgxpool.Pool,
|
||||
backendPID int,
|
||||
) {
|
||||
t.Helper()
|
||||
ticker := time.NewTicker(10 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
var waiting bool
|
||||
if err := pool.QueryRow(ctx, `
|
||||
SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_stat_activity AS activity
|
||||
WHERE activity.pid = $1
|
||||
AND activity.state = 'active'
|
||||
AND activity.wait_event_type = 'Lock'
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_locks AS waiting_lock
|
||||
WHERE waiting_lock.pid = activity.pid
|
||||
AND NOT waiting_lock.granted
|
||||
)
|
||||
)`, backendPID).Scan(&waiting); err != nil {
|
||||
t.Fatalf("observe delete backend lock wait: %v", err)
|
||||
}
|
||||
if waiting {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
t.Fatalf("backend %d never entered a PostgreSQL lock wait: %v", backendPID, ctx.Err())
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func saveTempIdentityTestAuthKey(
|
||||
t *testing.T,
|
||||
ctx context.Context,
|
||||
pool *pgxpool.Pool,
|
||||
keys store.AuthKeyStore,
|
||||
expiresAt int,
|
||||
) [8]byte {
|
||||
t.Helper()
|
||||
var id [8]byte
|
||||
var value [256]byte
|
||||
if _, err := rand.Read(id[:]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := rand.Read(value[:]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := keys.Save(ctx, store.AuthKeyData{ID: id, Value: value, ExpiresAt: expiresAt}); err != nil {
|
||||
t.Fatalf("save auth key: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = NewAuthKeyStore(pool).Delete(ctx, id)
|
||||
})
|
||||
return id
|
||||
}
|
||||
|
||||
func randomTempIdentityAuthKeyID(t *testing.T) [8]byte {
|
||||
t.Helper()
|
||||
var id [8]byte
|
||||
if _, err := rand.Read(id[:]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func assertTempIdentityBinding(
|
||||
t *testing.T,
|
||||
ctx context.Context,
|
||||
bindings store.TempAuthKeyBindingStore,
|
||||
want domain.TempAuthKeyBinding,
|
||||
) {
|
||||
t.Helper()
|
||||
got, found, err := bindings.GetByTemp(ctx, want.TempAuthKeyID)
|
||||
if err != nil {
|
||||
t.Fatalf("get binding: %v", err)
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("binding not found")
|
||||
}
|
||||
if got.TempAuthKeyID != want.TempAuthKeyID || got.PermAuthKeyID != want.PermAuthKeyID ||
|
||||
got.Nonce != want.Nonce || got.TempSessionID != want.TempSessionID || got.ExpiresAt != want.ExpiresAt ||
|
||||
!bytes.Equal(got.EncryptedMessage, want.EncryptedMessage) {
|
||||
t.Fatalf("binding mismatch: got %+v, want %+v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func assertTempIdentityAuthKeyExpiry(
|
||||
t *testing.T,
|
||||
ctx context.Context,
|
||||
keys store.AuthKeyStore,
|
||||
id [8]byte,
|
||||
want int,
|
||||
) {
|
||||
t.Helper()
|
||||
got, found, err := keys.Get(ctx, id)
|
||||
if err != nil {
|
||||
t.Fatalf("get auth key: %v", err)
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("auth key not found")
|
||||
}
|
||||
if got.ExpiresAt != want {
|
||||
t.Fatalf("auth key expires_at = %d, want %d", got.ExpiresAt, want)
|
||||
}
|
||||
}
|
||||
|
||||
func assertTempIdentityAuthKeyMissing(
|
||||
t *testing.T,
|
||||
ctx context.Context,
|
||||
keys store.AuthKeyStore,
|
||||
id [8]byte,
|
||||
) {
|
||||
t.Helper()
|
||||
if _, found, err := keys.Get(ctx, id); err != nil || found {
|
||||
t.Fatalf("auth key %x found=%v err=%v, want absent", id, found, err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertTempIdentityLayerTuple(
|
||||
t *testing.T,
|
||||
ctx context.Context,
|
||||
pool *pgxpool.Pool,
|
||||
authKeyID [8]byte,
|
||||
wantLayer int,
|
||||
wantObservationID int64,
|
||||
) {
|
||||
t.Helper()
|
||||
var (
|
||||
layer int
|
||||
observationID int64
|
||||
)
|
||||
if err := pool.QueryRow(ctx, `
|
||||
SELECT layer, layer_observation_id
|
||||
FROM auth_keys
|
||||
WHERE auth_key_id = $1`, authKeyIDToInt64(authKeyID)).Scan(&layer, &observationID); err != nil {
|
||||
t.Fatalf("read auth-key layer tuple: %v", err)
|
||||
}
|
||||
if layer != wantLayer || observationID != wantObservationID {
|
||||
t.Fatalf("auth-key layer tuple = (%d,%d), want (%d,%d)", layer, observationID, wantLayer, wantObservationID)
|
||||
}
|
||||
}
|
||||
|
||||
func assertTempIdentityAuthorizationLayer(
|
||||
t *testing.T,
|
||||
ctx context.Context,
|
||||
pool *pgxpool.Pool,
|
||||
authKeyID [8]byte,
|
||||
wantLayer int,
|
||||
) {
|
||||
t.Helper()
|
||||
var layer int
|
||||
if err := pool.QueryRow(ctx, `
|
||||
SELECT layer
|
||||
FROM authorizations
|
||||
WHERE auth_key_id = $1`, authKeyIDToInt64(authKeyID)).Scan(&layer); err != nil {
|
||||
t.Fatalf("read authorization layer mirror: %v", err)
|
||||
}
|
||||
if layer != wantLayer {
|
||||
t.Fatalf("authorization layer mirror = %d, want %d", layer, wantLayer)
|
||||
}
|
||||
}
|
||||
|
|
@ -505,8 +505,8 @@ RETURNING id
|
|||
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", userIDs)
|
||||
})
|
||||
if _, err := pool.Exec(ctx, `
|
||||
INSERT INTO auth_keys (auth_key_id, body, server_salt)
|
||||
SELECT id, decode(repeat('00', 256), 'hex'), 0
|
||||
INSERT INTO auth_keys (auth_key_id, body, server_salt, expires_at)
|
||||
SELECT id, decode(repeat('00', 256), 'hex'), 0, 0
|
||||
FROM unnest($1::bigint[]) AS id`, authKeyIDs); err != nil {
|
||||
t.Fatalf("bulk insert old-tail auth keys: %v", err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -55,16 +55,39 @@ func (s *UpdateStateStore) Save(ctx context.Context, id [8]byte, userID int64, s
|
|||
return nil
|
||||
}
|
||||
|
||||
func (s *UpdateStateStore) CommitDeliveredState(ctx context.Context, id [8]byte, userID int64, st domain.UpdateState, mode domain.UpdateStateCommitMode) error {
|
||||
establishObserved := mode == domain.UpdateStateCommitDeliveredAndObservedBaseline
|
||||
if mode != domain.UpdateStateCommitDeliveredOnly && !establishObserved {
|
||||
return fmt.Errorf("commit delivered update state: invalid mode %d", mode)
|
||||
}
|
||||
if _, err := s.db.Exec(ctx, `
|
||||
INSERT INTO update_states (auth_key_id, user_id, pts, qts, date, seq, observed_pts)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, CASE WHEN $7 THEN $3 ELSE 0 END)
|
||||
ON CONFLICT (auth_key_id, user_id) DO UPDATE SET
|
||||
pts = GREATEST(update_states.pts, EXCLUDED.pts),
|
||||
qts = GREATEST(update_states.qts, EXCLUDED.qts),
|
||||
date = GREATEST(update_states.date, EXCLUDED.date),
|
||||
seq = GREATEST(update_states.seq, EXCLUDED.seq),
|
||||
observed_pts = CASE
|
||||
WHEN $7 THEN GREATEST(update_states.observed_pts, EXCLUDED.observed_pts)
|
||||
ELSE update_states.observed_pts
|
||||
END,
|
||||
updated_at = now()`, authKeyIDToInt64(id), userID, st.Pts, st.Qts, st.Date, st.Seq, establishObserved); err != nil {
|
||||
return fmt.Errorf("commit delivered update state: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *UpdateStateStore) ObserveClientState(ctx context.Context, id [8]byte, userID int64, st domain.UpdateState) error {
|
||||
if st.Pts < 0 {
|
||||
st.Pts = 0
|
||||
}
|
||||
if _, err := s.db.Exec(ctx, `
|
||||
INSERT INTO update_states (auth_key_id, user_id, pts, qts, date, seq, observed_pts)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $3)
|
||||
VALUES ($1, $2, 0, 0, 0, 0, $3)
|
||||
ON CONFLICT (auth_key_id, user_id) DO UPDATE SET
|
||||
observed_pts = GREATEST(update_states.observed_pts, EXCLUDED.observed_pts),
|
||||
updated_at = now()`, authKeyIDToInt64(id), userID, st.Pts, st.Qts, st.Date, st.Seq); err != nil {
|
||||
updated_at = now()`, authKeyIDToInt64(id), userID, st.Pts); err != nil {
|
||||
return fmt.Errorf("observe client update state: %w", err)
|
||||
}
|
||||
return nil
|
||||
|
|
|
|||
79
internal/store/postgres/updatestate_delivery_test.go
Normal file
79
internal/store/postgres/updatestate_delivery_test.go
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
type captureUpdateStateDB struct {
|
||||
sql string
|
||||
args []any
|
||||
}
|
||||
|
||||
func (d *captureUpdateStateDB) Exec(_ context.Context, sql string, args ...any) (pgconn.CommandTag, error) {
|
||||
d.sql = sql
|
||||
d.args = append([]any(nil), args...)
|
||||
return pgconn.NewCommandTag("INSERT 0 1"), nil
|
||||
}
|
||||
|
||||
func (*captureUpdateStateDB) Query(context.Context, string, ...any) (pgx.Rows, error) {
|
||||
panic("unexpected Query")
|
||||
}
|
||||
|
||||
func (*captureUpdateStateDB) QueryRow(context.Context, string, ...any) pgx.Row {
|
||||
panic("unexpected QueryRow")
|
||||
}
|
||||
|
||||
func TestCommitDeliveredStateUsesOneAtomicBaselineUpsert(t *testing.T) {
|
||||
db := &captureUpdateStateDB{}
|
||||
store := NewUpdateStateStore(db)
|
||||
state := domain.UpdateState{Pts: 9, Qts: 2, Date: 90, Seq: 3}
|
||||
if err := store.CommitDeliveredState(context.Background(), [8]byte{4}, 1004, state, domain.UpdateStateCommitDeliveredAndObservedBaseline); err != nil {
|
||||
t.Fatalf("commit baseline: %v", err)
|
||||
}
|
||||
if len(db.args) != 7 || db.args[6] != true {
|
||||
t.Fatalf("baseline args = %#v, want final true mode", db.args)
|
||||
}
|
||||
for _, fragment := range []string{
|
||||
"pts = GREATEST(update_states.pts, EXCLUDED.pts)",
|
||||
"WHEN $7 THEN GREATEST(update_states.observed_pts, EXCLUDED.observed_pts)",
|
||||
} {
|
||||
if !strings.Contains(db.sql, fragment) {
|
||||
t.Fatalf("atomic commit SQL missing %q:\n%s", fragment, db.sql)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommitDeliveredOnlyLeavesObservedUntouched(t *testing.T) {
|
||||
db := &captureUpdateStateDB{}
|
||||
store := NewUpdateStateStore(db)
|
||||
if err := store.CommitDeliveredState(context.Background(), [8]byte{5}, 1005, domain.UpdateState{Pts: 7}, domain.UpdateStateCommitDeliveredOnly); err != nil {
|
||||
t.Fatalf("commit delivered-only: %v", err)
|
||||
}
|
||||
if len(db.args) != 7 || db.args[6] != false {
|
||||
t.Fatalf("delivered-only args = %#v, want final false mode", db.args)
|
||||
}
|
||||
if !strings.Contains(db.sql, "ELSE update_states.observed_pts") {
|
||||
t.Fatalf("delivered-only SQL can overwrite observed:\n%s", db.sql)
|
||||
}
|
||||
}
|
||||
|
||||
func TestObserveClientStateDoesNotFabricateConfirmedCursor(t *testing.T) {
|
||||
db := &captureUpdateStateDB{}
|
||||
store := NewUpdateStateStore(db)
|
||||
if err := store.ObserveClientState(context.Background(), [8]byte{6}, 1006, domain.UpdateState{Pts: 11, Qts: 4, Date: 110, Seq: 2}); err != nil {
|
||||
t.Fatalf("observe request: %v", err)
|
||||
}
|
||||
if !strings.Contains(db.sql, "VALUES ($1, $2, 0, 0, 0, 0, $3)") {
|
||||
t.Fatalf("observed-only insert fabricated confirmed values:\n%s", db.sql)
|
||||
}
|
||||
if len(db.args) != 3 || db.args[2] != 11 {
|
||||
t.Fatalf("observed-only args = %#v, want auth/user/pts", db.args)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue