owpengram-server/internal/store/postgres/auth_identity_lock_integration_test.go
2026-09-01 12:06:31 +03:00

437 lines
14 KiB
Go

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)
}
msgID := authKeySessionLayerTestMsgID(time.Now().UTC(), 1)
type advanceResult struct {
value store.AuthKeySessionLayer
applied bool
err error
}
// Stage the first binding but do not commit it. Save holds the permanent
// identity gate and raw row, so the selector sees the old unbound hint and
// then waits on the raw row inside the server-side advance function.
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)
}
result := make(chan advanceResult, 1)
go func() {
value, applied, err := NewAuthKeyStore(advanceConn).AdvanceSessionLayer(ctx, temp, 8703, 227, msgID)
result <- advanceResult{value: value, applied: applied, err: err}
}()
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)
}
assertRevokeTestNoAuthorization(t, ctx, auths, perm)
if op == "revoke" {
// Remote authorization revocation deliberately preserves protocol
// keys and their binding so reconnect reaches the RPC authorization
// gate and receives AUTH_KEY_UNREGISTERED rather than transport -404.
assertRevokeTestPresentAuthKey(t, ctx, keys, temp)
assertRevokeTestPresentAuthKey(t, ctx, keys, perm)
if _, found, err := bindings.GetByTemp(ctx, temp); err != nil || !found {
t.Fatalf("binding after revoke found=%v err=%v, want present", found, err)
}
return
}
assertTempIdentityAuthKeyMissing(t, ctx, keys, temp)
assertTempIdentityAuthKeyMissing(t, ctx, keys, perm)
if _, found, err := bindings.GetByTemp(ctx, temp); err != nil || found {
t.Fatalf("binding after delete found=%v err=%v, want absent", 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
}