Ctrl K
ComunidadGuías…

internal/repository/user_postgres_test.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// Integration tests for the guarded user queries: the "at least one admin"
// invariant is enforced by SQL (FOR UPDATE on the admin rows), which the
// in-memory fakes cannot exercise. They need a real Postgres and skip unless
// TEST_DATABASE_URL is set, e.g.:
//
//	docker run -d --name eoo-pg -e POSTGRES_PASSWORD=test -p 55432:5432 postgres:16-alpine
//	TEST_DATABASE_URL="postgres://postgres:test@localhost:55432/postgres?sslmode=disable" go test ./internal/store/
//
// The package is store_test to avoid an import cycle (store -> database/postgres -> migrations -> store).
package repository_test

import (
	"context"
	"errors"
	"os"
	"sync"
	"testing"

	"xprem/internal/database"
	"xprem/internal/database/postgres"
	"xprem/internal/database/postgres/pgdb"
	"xprem/internal/repository"

	"github.com/google/uuid"
	"github.com/jackc/pgx/v5/pgxpool"
	"github.com/stretchr/testify/require"
)

func setupUserStore(t *testing.T) (*repository.PostgresUserRepository, *pgxpool.Pool) {
	t.Helper()
	dbURL := os.Getenv("TEST_DATABASE_URL")
	if dbURL == "" {
		if os.Getenv("CI") != "" {
			t.Fatal("TEST_DATABASE_URL must be set in CI: these tests cover SQL that the in-memory fakes cannot reach")
		}
		t.Skip("TEST_DATABASE_URL not set, start a Postgres and set it to run the guarded-query tests")
	}
	// The seed migration fails fast on an empty database without the bootstrap pair.
	t.Setenv("ADMIN_EMAIL", "[email protected]")
	t.Setenv("ADMIN_PASSWORD", "Sup3rSecret!")
	postgres.RunDBMigrations(dbURL)

	pool, err := pgxpool.New(context.Background(), dbURL)
	require.NoError(t, err)
	t.Cleanup(pool.Close)
	return repository.NewPostgresUserRepository(&database.Engine{Queries: pgdb.New(pool), DB: pool}), pool
}

func resetUsers(t *testing.T, pool *pgxpool.Pool) {
	t.Helper()
	_, err := pool.Exec(context.Background(), "DELETE FROM users")
	require.NoError(t, err)
}

func insertUser(t *testing.T, userStore *repository.PostgresUserRepository, email string, isAdmin bool) repository.User {
	t.Helper()
	user, err := userStore.InsertUser(context.Background(), repository.InsertUserParameters{
		ID:           uuid.NewString(),
		Email:        email,
		PasswordHash: "irrelevant",
		IsAdmin:      isAdmin,
		// Must be true: the guards count admins as `is_admin AND enabled`, so a
		// disabled fixture admin wouldn't protect the guard and tests would pass falsely.
		Enabled: true,
	})
	require.NoError(t, err)
	return user
}

func countAdmins(t *testing.T, pool *pgxpool.Pool) int64 {
	t.Helper()
	var count int64
	require.NoError(t, pool.QueryRow(context.Background(), "SELECT COUNT(*) FROM users WHERE is_admin").Scan(&count))
	return count
}

func TestGuardedQueriesRefuseRemovingLastAdmin(t *testing.T) {
	userStore, pool := setupUserStore(t)
	ctx := context.Background()
	resetUsers(t, pool)

	admin := insertUser(t, userStore, "[email protected]", true)
	member := insertUser(t, userStore, "[email protected]", false)

	// The sole admin can neither be demoted nor deleted.
	require.ErrorIs(t, userStore.UpdateUserIsAdmin(ctx, admin.Id, false), repository.ErrWouldLeaveNoAdmin)
	require.ErrorIs(t, userStore.DeleteUserByID(ctx, admin.Id), repository.ErrWouldLeaveNoAdmin)

	// Members pass the guard, and unknown ids still read as not-found.
	require.NoError(t, userStore.DeleteUserByID(ctx, member.Id))
	notFoundErr := (*repository.ErrResourceNotFound)(nil)
	require.ErrorAs(t, userStore.DeleteUserByID(ctx, uuid.NewString()), &notFoundErr)
	require.ErrorAs(t, userStore.UpdateUserIsAdmin(ctx, uuid.NewString(), false), &notFoundErr)

	// With a second admin the demotion goes through, once.
	insertUser(t, userStore, "[email protected]", true)
	require.NoError(t, userStore.UpdateUserIsAdmin(ctx, admin.Id, false))
	require.EqualValues(t, 1, countAdmins(t, pool))
}

// TestGuardedQueriesUnderConcurrentRemovals has two admins remove each other at
// the same instant, repeatedly; exactly one operation must win and exactly one
// admin must remain.
func TestGuardedQueriesUnderConcurrentRemovals(t *testing.T) {
	userStore, pool := setupUserStore(t)
	ctx := context.Background()

	type operation func(targetId string) error
	demote := func(targetId string) error { return userStore.UpdateUserIsAdmin(ctx, targetId, false) }
	remove := func(targetId string) error { return userStore.DeleteUserByID(ctx, targetId) }

	scenarios := []struct {
		name          string
		first, second operation
	}{
		{"demote vs demote", demote, demote},
		{"delete vs delete", remove, remove},
		{"demote vs delete", demote, remove},
	}

	for _, scenario := range scenarios {
		t.Run(scenario.name, func(t *testing.T) {
			for i := 0; i < 20; i++ {
				resetUsers(t, pool)
				adminA := insertUser(t, userStore, "[email protected]", true)
				adminB := insertUser(t, userStore, "[email protected]", true)

				start := make(chan struct{})
				results := make([]error, 2)
				var wg sync.WaitGroup
				wg.Add(2)
				go func() { defer wg.Done(); <-start; results[0] = scenario.first(adminB.Id) }()
				go func() { defer wg.Done(); <-start; results[1] = scenario.second(adminA.Id) }()
				close(start)
				wg.Wait()

				succeeded := 0
				for _, opErr := range results {
					if opErr == nil {
						succeeded++
					} else {
						require.True(t, errors.Is(opErr, repository.ErrWouldLeaveNoAdmin),
							"the losing operation must fail with ErrWouldLeaveNoAdmin, got: %v", opErr)
					}
				}
				require.Equal(t, 1, succeeded, "exactly one of the two concurrent removals must win")
				require.EqualValues(t, 1, countAdmins(t, pool), "exactly one admin must remain")
			}
		})
	}
}

// TestSessionVersionMovesOnlyWhenAccessIsLost verifies the session version
// bumps only when access is actually lost, not on every guarded update.
func TestSessionVersionMovesOnlyWhenAccessIsLost(t *testing.T) {
	userStore, pool := setupUserStore(t)
	ctx := context.Background()
	resetUsers(t, pool)

	// Stays admin and enabled throughout, so the "at least one admin" guard
	// never refuses an operation on the account under test.
	insertUser(t, userStore, "[email protected]", true)
	member := insertUser(t, userStore, "[email protected]", false)

	versionOf := func() int32 {
		t.Helper()
		user, err := userStore.GetUserByID(ctx, member.Id)
		require.NoError(t, err)
		return user.SessionVersion
	}
	require.EqualValues(t, 0, versionOf())

	// A new password always ends the sessions minted under the old one.
	require.NoError(t, userStore.UpdateUserPassword(ctx, member.Id, "new-hash"))
	require.EqualValues(t, 1, versionOf())

	// Losing access ends them too.
	require.NoError(t, userStore.UpdateUserEnabled(ctx, member.Id, false))
	require.EqualValues(t, 2, versionOf())

	// Regaining access does not: approving an account must not sign anybody out.
	require.NoError(t, userStore.UpdateUserEnabled(ctx, member.Id, true))
	require.EqualValues(t, 2, versionOf())

	// Neither does gaining the admin flag.
	require.NoError(t, userStore.UpdateUserIsAdmin(ctx, member.Id, true))
	require.EqualValues(t, 2, versionOf())

	// Losing it does.
	require.NoError(t, userStore.UpdateUserIsAdmin(ctx, member.Id, false))
	require.EqualValues(t, 3, versionOf())

	// An idempotent write is not a transition: repeating either call on an
	// account already in that state must leave the version alone.
	require.NoError(t, userStore.UpdateUserIsAdmin(ctx, member.Id, false))
	require.NoError(t, userStore.UpdateUserEnabled(ctx, member.Id, true))
	require.EqualValues(t, 3, versionOf())
}