feat(store): миграция sessions/password + методы users/sessions/projects
Фаза 2, Task 1: добавлена таблица sessions и nullable password_hash у users, sqlc-запросы и *Store-обёртки (CreateUser, GetUserByEmail, CreateProjectForUser, GetProjectOwned, GetUserProject, CreateSession, GetSessionUser, DeleteSession, RegisterUser в транзакции), интеграционные тесты на testcontainers. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwxdSt4reTm7Dj1oxRvpP3
This commit is contained in:
@@ -0,0 +1,149 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// TestRegisterUser_CreatesUserAndOwnedProject verifies the RegisterUser
|
||||
// transaction: a user and a default project are created together, and the
|
||||
// project belongs to that user.
|
||||
func TestRegisterUser_CreatesUserAndOwnedProject(t *testing.T) {
|
||||
s, ctx := newStore(t)
|
||||
|
||||
u, p, err := s.RegisterUser(ctx, "alice@example.com", "argon2-hash")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if u.Email != "alice@example.com" || u.PasswordHash != "argon2-hash" {
|
||||
t.Fatalf("unexpected user: %+v", u)
|
||||
}
|
||||
if p.UserID != u.ID {
|
||||
t.Fatalf("expected project to belong to user %s, got %+v", u.ID, p)
|
||||
}
|
||||
|
||||
owned, err := s.GetProjectOwned(ctx, p.ID, u.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if owned.ID != p.ID {
|
||||
t.Fatalf("expected owned project %s, got %+v", p.ID, owned)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetUserByEmail_FindsRegisteredUser verifies email lookup returns the
|
||||
// same user created by RegisterUser.
|
||||
func TestGetUserByEmail_FindsRegisteredUser(t *testing.T) {
|
||||
s, ctx := newStore(t)
|
||||
|
||||
u, _, err := s.RegisterUser(ctx, "bob@example.com", "argon2-hash")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := s.GetUserByEmail(ctx, "bob@example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.ID != u.ID || got.PasswordHash != "argon2-hash" {
|
||||
t.Fatalf("unexpected user: %+v", got)
|
||||
}
|
||||
|
||||
if _, err := s.GetUserByEmail(ctx, "nobody@example.com"); err == nil {
|
||||
t.Fatal("expected error for unknown email, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSessionLifecycle_CreateGetDelete verifies CreateSession + GetSessionUser
|
||||
// round-trips to the owning user ID, an expired session is excluded from
|
||||
// GetSessionUser, and DeleteSession removes the session.
|
||||
func TestSessionLifecycle_CreateGetDelete(t *testing.T) {
|
||||
s, ctx := newStore(t)
|
||||
|
||||
u, _, err := s.RegisterUser(ctx, "carol@example.com", "argon2-hash")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
tokenHash := "sha256-token-hash"
|
||||
if err := s.CreateSession(ctx, u.ID, tokenHash, time.Now().Add(time.Hour)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
gotUserID, err := s.GetSessionUser(ctx, tokenHash)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if gotUserID != u.ID {
|
||||
t.Fatalf("expected user %s, got %s", u.ID, gotUserID)
|
||||
}
|
||||
|
||||
if err := s.DeleteSession(ctx, tokenHash); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.GetSessionUser(ctx, tokenHash); err == nil {
|
||||
t.Fatal("expected error after DeleteSession, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetSessionUser_ExpiredSessionNotReturned verifies the query's
|
||||
// expires_at > now() condition: a session created with an expiry in the
|
||||
// past must not be returned by GetSessionUser.
|
||||
func TestGetSessionUser_ExpiredSessionNotReturned(t *testing.T) {
|
||||
s, ctx := newStore(t)
|
||||
|
||||
u, _, err := s.RegisterUser(ctx, "dave@example.com", "argon2-hash")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
tokenHash := "expired-token-hash"
|
||||
if err := s.CreateSession(ctx, u.ID, tokenHash, time.Now().Add(-time.Hour)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := s.GetSessionUser(ctx, tokenHash); err == nil {
|
||||
t.Fatal("expected expired session to not be returned, got nil error")
|
||||
} else if err != pgx.ErrNoRows {
|
||||
t.Fatalf("expected pgx.ErrNoRows, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetProjectOwned_ForeignUserRejected verifies that looking up a project
|
||||
// with the wrong user ID fails, so one tenant cannot address another
|
||||
// tenant's project by guessing its ID.
|
||||
func TestGetProjectOwned_ForeignUserRejected(t *testing.T) {
|
||||
s, ctx := newStore(t)
|
||||
|
||||
_, p, err := s.RegisterUser(ctx, "erin@example.com", "argon2-hash")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
foreignUserID := uuid.New()
|
||||
if _, err := s.GetProjectOwned(ctx, p.ID, foreignUserID); err == nil {
|
||||
t.Fatal("expected error for foreign user ID, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetUserProject_ReturnsTheUsersProject verifies GetUserProject returns
|
||||
// the project created for that user by RegisterUser.
|
||||
func TestGetUserProject_ReturnsTheUsersProject(t *testing.T) {
|
||||
s, ctx := newStore(t)
|
||||
|
||||
u, p, err := s.RegisterUser(ctx, "frank@example.com", "argon2-hash")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := s.GetUserProject(ctx, u.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.ID != p.ID {
|
||||
t.Fatalf("expected project %s, got %+v", p.ID, got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user