From 3bd237d5627e450062af5ffd5bdd9a9bc4b8c6cf Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Fri, 3 Jul 2026 19:44:36 +0700 Subject: [PATCH] =?UTF-8?q?feat(store):=20=D0=BC=D0=B8=D0=B3=D1=80=D0=B0?= =?UTF-8?q?=D1=86=D0=B8=D1=8F=20sessions/password=20+=20=D0=BC=D0=B5=D1=82?= =?UTF-8?q?=D0=BE=D0=B4=D1=8B=20users/sessions/projects?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Фаза 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) Claude-Session: https://claude.ai/code/session_01BwxdSt4reTm7Dj1oxRvpP3 --- internal/store/auth_test.go | 149 ++++++++++++++++++++++++ internal/store/db/models.go | 15 ++- internal/store/db/projects.sql.go | 71 +++++++++++ internal/store/db/sessions.sql.go | 54 +++++++++ internal/store/db/users.sql.go | 50 ++++++++ internal/store/migrations/0003_auth.sql | 15 +++ internal/store/queries/projects.sql | 8 ++ internal/store/queries/sessions.sql | 8 ++ internal/store/queries/users.sql | 5 + internal/store/tenant.go | 126 ++++++++++++++++++++ 10 files changed, 498 insertions(+), 3 deletions(-) create mode 100644 internal/store/auth_test.go create mode 100644 internal/store/db/projects.sql.go create mode 100644 internal/store/db/sessions.sql.go create mode 100644 internal/store/db/users.sql.go create mode 100644 internal/store/migrations/0003_auth.sql create mode 100644 internal/store/queries/projects.sql create mode 100644 internal/store/queries/sessions.sql create mode 100644 internal/store/queries/users.sql diff --git a/internal/store/auth_test.go b/internal/store/auth_test.go new file mode 100644 index 0000000..2bedd35 --- /dev/null +++ b/internal/store/auth_test.go @@ -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) + } +} diff --git a/internal/store/db/models.go b/internal/store/db/models.go index d3b98ab..d541484 100644 --- a/internal/store/db/models.go +++ b/internal/store/db/models.go @@ -43,6 +43,14 @@ type ProviderAccount struct { CreatedAt pgtype.Timestamptz `json:"created_at"` } +type Session struct { + ID uuid.UUID `json:"id"` + UserID uuid.UUID `json:"user_id"` + TokenHash string `json:"token_hash"` + ExpiresAt pgtype.Timestamptz `json:"expires_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + type Template struct { ID uuid.UUID `json:"id"` ProjectID uuid.UUID `json:"project_id"` @@ -54,7 +62,8 @@ type Template struct { } type User struct { - ID uuid.UUID `json:"id"` - Email string `json:"email"` - CreatedAt pgtype.Timestamptz `json:"created_at"` + ID uuid.UUID `json:"id"` + Email string `json:"email"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + PasswordHash *string `json:"password_hash"` } diff --git a/internal/store/db/projects.sql.go b/internal/store/db/projects.sql.go new file mode 100644 index 0000000..4e0d435 --- /dev/null +++ b/internal/store/db/projects.sql.go @@ -0,0 +1,71 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: projects.sql + +package db + +import ( + "context" + + "github.com/google/uuid" +) + +const createProject = `-- name: CreateProject :one +INSERT INTO projects (id, user_id, name) VALUES ($1, $2, $3) RETURNING id, user_id, name, created_at +` + +type CreateProjectParams struct { + ID uuid.UUID `json:"id"` + UserID uuid.UUID `json:"user_id"` + Name string `json:"name"` +} + +func (q *Queries) CreateProject(ctx context.Context, arg CreateProjectParams) (Project, error) { + row := q.db.QueryRow(ctx, createProject, arg.ID, arg.UserID, arg.Name) + var i Project + err := row.Scan( + &i.ID, + &i.UserID, + &i.Name, + &i.CreatedAt, + ) + return i, err +} + +const getProjectOwned = `-- name: GetProjectOwned :one +SELECT id, user_id, name, created_at FROM projects WHERE id = $1 AND user_id = $2 +` + +type GetProjectOwnedParams struct { + ID uuid.UUID `json:"id"` + UserID uuid.UUID `json:"user_id"` +} + +func (q *Queries) GetProjectOwned(ctx context.Context, arg GetProjectOwnedParams) (Project, error) { + row := q.db.QueryRow(ctx, getProjectOwned, arg.ID, arg.UserID) + var i Project + err := row.Scan( + &i.ID, + &i.UserID, + &i.Name, + &i.CreatedAt, + ) + return i, err +} + +const getUserProject = `-- name: GetUserProject :one +SELECT id, user_id, name, created_at FROM projects WHERE user_id = $1 ORDER BY created_at LIMIT 1 +` + +func (q *Queries) GetUserProject(ctx context.Context, userID uuid.UUID) (Project, error) { + row := q.db.QueryRow(ctx, getUserProject, userID) + var i Project + err := row.Scan( + &i.ID, + &i.UserID, + &i.Name, + &i.CreatedAt, + ) + return i, err +} diff --git a/internal/store/db/sessions.sql.go b/internal/store/db/sessions.sql.go new file mode 100644 index 0000000..bfd6286 --- /dev/null +++ b/internal/store/db/sessions.sql.go @@ -0,0 +1,54 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: sessions.sql + +package db + +import ( + "context" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" +) + +const createSession = `-- name: CreateSession :exec +INSERT INTO sessions (id, user_id, token_hash, expires_at) VALUES ($1, $2, $3, $4) +` + +type CreateSessionParams struct { + ID uuid.UUID `json:"id"` + UserID uuid.UUID `json:"user_id"` + TokenHash string `json:"token_hash"` + ExpiresAt pgtype.Timestamptz `json:"expires_at"` +} + +func (q *Queries) CreateSession(ctx context.Context, arg CreateSessionParams) error { + _, err := q.db.Exec(ctx, createSession, + arg.ID, + arg.UserID, + arg.TokenHash, + arg.ExpiresAt, + ) + return err +} + +const deleteSession = `-- name: DeleteSession :exec +DELETE FROM sessions WHERE token_hash = $1 +` + +func (q *Queries) DeleteSession(ctx context.Context, tokenHash string) error { + _, err := q.db.Exec(ctx, deleteSession, tokenHash) + return err +} + +const getSessionUser = `-- name: GetSessionUser :one +SELECT user_id FROM sessions WHERE token_hash = $1 AND expires_at > now() +` + +func (q *Queries) GetSessionUser(ctx context.Context, tokenHash string) (uuid.UUID, error) { + row := q.db.QueryRow(ctx, getSessionUser, tokenHash) + var user_id uuid.UUID + err := row.Scan(&user_id) + return user_id, err +} diff --git a/internal/store/db/users.sql.go b/internal/store/db/users.sql.go new file mode 100644 index 0000000..c76c410 --- /dev/null +++ b/internal/store/db/users.sql.go @@ -0,0 +1,50 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: users.sql + +package db + +import ( + "context" + + "github.com/google/uuid" +) + +const createUser = `-- name: CreateUser :one +INSERT INTO users (id, email, password_hash) VALUES ($1, $2, $3) RETURNING id, email, created_at, password_hash +` + +type CreateUserParams struct { + ID uuid.UUID `json:"id"` + Email string `json:"email"` + PasswordHash *string `json:"password_hash"` +} + +func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, error) { + row := q.db.QueryRow(ctx, createUser, arg.ID, arg.Email, arg.PasswordHash) + var i User + err := row.Scan( + &i.ID, + &i.Email, + &i.CreatedAt, + &i.PasswordHash, + ) + return i, err +} + +const getUserByEmail = `-- name: GetUserByEmail :one +SELECT id, email, created_at, password_hash FROM users WHERE email = $1 +` + +func (q *Queries) GetUserByEmail(ctx context.Context, email string) (User, error) { + row := q.db.QueryRow(ctx, getUserByEmail, email) + var i User + err := row.Scan( + &i.ID, + &i.Email, + &i.CreatedAt, + &i.PasswordHash, + ) + return i, err +} diff --git a/internal/store/migrations/0003_auth.sql b/internal/store/migrations/0003_auth.sql new file mode 100644 index 0000000..2aa0ed4 --- /dev/null +++ b/internal/store/migrations/0003_auth.sql @@ -0,0 +1,15 @@ +-- +goose Up +ALTER TABLE users ADD COLUMN password_hash text; + +CREATE TABLE sessions ( + id uuid PRIMARY KEY, + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash text NOT NULL UNIQUE, + expires_at timestamptz NOT NULL, + created_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX sessions_token_hash_idx ON sessions (token_hash); + +-- +goose Down +DROP TABLE sessions; +ALTER TABLE users DROP COLUMN password_hash; diff --git a/internal/store/queries/projects.sql b/internal/store/queries/projects.sql new file mode 100644 index 0000000..cc77dc4 --- /dev/null +++ b/internal/store/queries/projects.sql @@ -0,0 +1,8 @@ +-- name: CreateProject :one +INSERT INTO projects (id, user_id, name) VALUES ($1, $2, $3) RETURNING *; + +-- name: GetProjectOwned :one +SELECT * FROM projects WHERE id = $1 AND user_id = $2; + +-- name: GetUserProject :one +SELECT * FROM projects WHERE user_id = $1 ORDER BY created_at LIMIT 1; diff --git a/internal/store/queries/sessions.sql b/internal/store/queries/sessions.sql new file mode 100644 index 0000000..337e3f7 --- /dev/null +++ b/internal/store/queries/sessions.sql @@ -0,0 +1,8 @@ +-- name: CreateSession :exec +INSERT INTO sessions (id, user_id, token_hash, expires_at) VALUES ($1, $2, $3, $4); + +-- name: GetSessionUser :one +SELECT user_id FROM sessions WHERE token_hash = $1 AND expires_at > now(); + +-- name: DeleteSession :exec +DELETE FROM sessions WHERE token_hash = $1; diff --git a/internal/store/queries/users.sql b/internal/store/queries/users.sql new file mode 100644 index 0000000..d68dc45 --- /dev/null +++ b/internal/store/queries/users.sql @@ -0,0 +1,5 @@ +-- name: CreateUser :one +INSERT INTO users (id, email, password_hash) VALUES ($1, $2, $3) RETURNING *; + +-- name: GetUserByEmail :one +SELECT * FROM users WHERE email = $1; diff --git a/internal/store/tenant.go b/internal/store/tenant.go index 724f811..37961a0 100644 --- a/internal/store/tenant.go +++ b/internal/store/tenant.go @@ -3,9 +3,11 @@ package store import ( "context" "errors" + "time" "github.com/google/uuid" "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" "github.com/vasyakrg/dns-autoresolver/internal/provider" "github.com/vasyakrg/dns-autoresolver/internal/store/db" @@ -222,3 +224,127 @@ func (s *Store) SetDomainTemplate(ctx context.Context, domainID, projectID uuid. } return domainFromDB(d), nil } + +// User and Project are provider-neutral domain structs for the auth/tenant +// layer (Фаза 2), mirroring the Account/Template/Domain wrappers above so +// callers never need to import internal/store/db directly. + +type User struct { + ID uuid.UUID + Email string + PasswordHash string +} + +type Project struct { + ID uuid.UUID + UserID uuid.UUID + Name string +} + +// ptr is a small helper for passing a Go string into a nullable text column +// (password_hash) via sqlc's generated *string param type. +func ptr(s string) *string { return &s } + +// strFromPtr converts a nullable text column back into a plain string; a +// nil password_hash never happens on the real registration flow (an argon2 +// hash is always supplied), but is handled defensively here. +func strFromPtr(p *string) string { + if p == nil { + return "" + } + return *p +} + +func toUser(u db.User) User { + return User{ID: u.ID, Email: u.Email, PasswordHash: strFromPtr(u.PasswordHash)} +} + +func toProject(p db.Project) Project { + return Project{ID: p.ID, UserID: p.UserID, Name: p.Name} +} + +func (s *Store) CreateUser(ctx context.Context, email, passwordHash string) (User, error) { + u, err := s.q.CreateUser(ctx, db.CreateUserParams{ID: uuid.New(), Email: email, PasswordHash: ptr(passwordHash)}) + if err != nil { + return User{}, err + } + return toUser(u), nil +} + +func (s *Store) GetUserByEmail(ctx context.Context, email string) (User, error) { + u, err := s.q.GetUserByEmail(ctx, email) + if err != nil { + return User{}, err + } + return toUser(u), nil +} + +func (s *Store) CreateProjectForUser(ctx context.Context, userID uuid.UUID, name string) (Project, error) { + p, err := s.q.CreateProject(ctx, db.CreateProjectParams{ID: uuid.New(), UserID: userID, Name: name}) + if err != nil { + return Project{}, err + } + return toProject(p), nil +} + +func (s *Store) GetProjectOwned(ctx context.Context, projectID, userID uuid.UUID) (Project, error) { + p, err := s.q.GetProjectOwned(ctx, db.GetProjectOwnedParams{ID: projectID, UserID: userID}) + if err != nil { + return Project{}, err + } + return toProject(p), nil +} + +func (s *Store) GetUserProject(ctx context.Context, userID uuid.UUID) (Project, error) { + p, err := s.q.GetUserProject(ctx, userID) + if err != nil { + return Project{}, err + } + return toProject(p), nil +} + +func (s *Store) CreateSession(ctx context.Context, userID uuid.UUID, tokenHash string, expiresAt time.Time) error { + return s.q.CreateSession(ctx, db.CreateSessionParams{ + ID: uuid.New(), + UserID: userID, + TokenHash: tokenHash, + ExpiresAt: pgtype.Timestamptz{Time: expiresAt, Valid: true}, + }) +} + +// GetSessionUser returns the owning user ID for a non-expired session token; +// expired sessions are excluded by the query itself (expires_at > now()). +func (s *Store) GetSessionUser(ctx context.Context, tokenHash string) (uuid.UUID, error) { + return s.q.GetSessionUser(ctx, tokenHash) +} + +func (s *Store) DeleteSession(ctx context.Context, tokenHash string) error { + return s.q.DeleteSession(ctx, tokenHash) +} + +// RegisterUser creates a user and their default project in one transaction, +// mirroring the ImportDomains pattern above: if project creation fails, the +// user insert is rolled back too, so a caller never observes a user without +// a default project. +func (s *Store) RegisterUser(ctx context.Context, email, passwordHash string) (User, Project, error) { + tx, err := s.pool.Begin(ctx) + if err != nil { + return User{}, Project{}, err + } + defer tx.Rollback(ctx) // no-op once Commit has succeeded + + q := s.q.WithTx(tx) + uid := uuid.New() + dbu, err := q.CreateUser(ctx, db.CreateUserParams{ID: uid, Email: email, PasswordHash: ptr(passwordHash)}) + if err != nil { + return User{}, Project{}, err + } + dbp, err := q.CreateProject(ctx, db.CreateProjectParams{ID: uuid.New(), UserID: uid, Name: "default"}) + if err != nil { + return User{}, Project{}, err + } + if err := tx.Commit(ctx); err != nil { + return User{}, Project{}, err + } + return toUser(dbu), toProject(dbp), nil +}