186 lines
5.2 KiB
Go
186 lines
5.2 KiB
Go
package store
|
|
|
|
import (
|
|
"errors"
|
|
"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")
|
|
}
|
|
}
|
|
|
|
// TestRegisterUser_DuplicateEmailReturnsErrEmailTaken verifies the fix for
|
|
// the duplicate-registration gap: a second RegisterUser call for an
|
|
// already-taken email must fail with the ErrEmailTaken sentinel (mapped from
|
|
// the UNIQUE constraint violation on users.email), not a generic pgx error.
|
|
func TestRegisterUser_DuplicateEmailReturnsErrEmailTaken(t *testing.T) {
|
|
s, ctx := newStore(t)
|
|
|
|
if _, _, err := s.RegisterUser(ctx, "dup@example.com", "argon2-hash"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if _, _, err := s.RegisterUser(ctx, "dup@example.com", "argon2-hash"); !errors.Is(err, ErrEmailTaken) {
|
|
t.Fatalf("expected ErrEmailTaken, got %v", err)
|
|
}
|
|
}
|
|
|
|
// TestGetUserByID_ReturnsUser verifies the fix for the /me gap: GetUserByID
|
|
// returns the same user created by RegisterUser, including their real email.
|
|
func TestGetUserByID_ReturnsUser(t *testing.T) {
|
|
s, ctx := newStore(t)
|
|
|
|
u, _, err := s.RegisterUser(ctx, "gina@example.com", "argon2-hash")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
got, err := s.GetUserByID(ctx, u.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got.ID != u.ID || got.Email != "gina@example.com" {
|
|
t.Fatalf("unexpected user: %+v", got)
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|