685 lines
27 KiB
Go
685 lines
27 KiB
Go
package repository
|
||
|
||
import (
|
||
"errors"
|
||
"testing"
|
||
"time"
|
||
|
||
"github.com/google/uuid"
|
||
|
||
"building-mgmt/internal/models"
|
||
)
|
||
|
||
// TestProfileEmailNormalization — email хранится и ищется без учёта регистра
|
||
// (индекс profiles_email_lower_key из миграции 0003).
|
||
func TestProfileEmailNormalization(t *testing.T) {
|
||
q, ctx := newTestQuerier(t)
|
||
repo := NewProfileRepo(q)
|
||
|
||
p := &models.Profile{
|
||
ID: uuid.NewString(),
|
||
Email: " Mixed.Case@Example.TEST ",
|
||
PasswordHash: "hash",
|
||
Name: "mixed",
|
||
Role: "client",
|
||
IsActive: true,
|
||
}
|
||
if err := repo.Create(ctx, p); err != nil {
|
||
t.Fatalf("create: %v", err)
|
||
}
|
||
if p.Email != "mixed.case@example.test" {
|
||
t.Errorf("stored email = %q, want normalized %q", p.Email, "mixed.case@example.test")
|
||
}
|
||
|
||
// Поиск с другим регистром находит того же пользователя.
|
||
got, err := repo.GetByEmail(ctx, "MIXED.CASE@EXAMPLE.test")
|
||
if err != nil {
|
||
t.Fatalf("get by email (other case): %v", err)
|
||
}
|
||
if got.ID != p.ID {
|
||
t.Errorf("got profile %s, want %s", got.ID, p.ID)
|
||
}
|
||
|
||
// Дубликат в другом регистре — тот же адрес, 409.
|
||
dup := &models.Profile{
|
||
ID: uuid.NewString(),
|
||
Email: "mixed.case@example.test",
|
||
PasswordHash: "hash",
|
||
Name: "dup",
|
||
Role: "client",
|
||
IsActive: true,
|
||
}
|
||
expectError(t, ctx, q, "duplicate email (other case)", func() error {
|
||
return repo.Create(ctx, dup)
|
||
}, ErrConflict)
|
||
|
||
// Неизвестный адрес — не 409, а ровно «не найдено».
|
||
if _, err := repo.GetByEmail(ctx, "nobody@example.test"); !errors.Is(err, ErrNotFound) {
|
||
t.Errorf("unknown email: err = %v, want ErrNotFound", err)
|
||
}
|
||
}
|
||
|
||
// TestSearchByRole — поиск мастера для приглашения в проект: подстрока
|
||
// без учёта регистра, спецсимволы ILIKE ищутся буквально, выключенные
|
||
// аккаунты и другие роли в результат не попадают.
|
||
func TestSearchByRole(t *testing.T) {
|
||
q, ctx := newTestQuerier(t)
|
||
repo := NewProfileRepo(q)
|
||
|
||
pro := func(name, email, role string, active bool) {
|
||
t.Helper()
|
||
p := &models.Profile{
|
||
ID: uuid.NewString(), Email: email, PasswordHash: "test-hash",
|
||
Name: name, Role: role, IsActive: active,
|
||
}
|
||
if err := repo.Create(ctx, p); err != nil {
|
||
t.Fatalf("create %s: %v", email, err)
|
||
}
|
||
}
|
||
pro("Петр Сидоров", "sidorov@site.test", "worker", true)
|
||
pro("Petr Upper", "petr-upper@site.test", "worker", true)
|
||
pro("Петр Выкл", "petr-off@site.test", "worker", false) // выключен — мимо
|
||
pro("Петр Клиент", "petr-client@site.test", "client", true) // чужая роль — мимо
|
||
pro("Мастер 100%", "percent@site.test", "worker", true)
|
||
|
||
t.Run("кириллица без учёта регистра", func(t *testing.T) {
|
||
got, total, err := repo.SearchByRole(ctx, "worker", "петр", false, Page{Limit: MaxLimit})
|
||
if err != nil {
|
||
t.Fatalf("search: %v", err)
|
||
}
|
||
if total != 1 || len(got) != 1 || got[0].Email != "sidorov@site.test" {
|
||
t.Errorf("got %d/%d %+v, want ровно sidorov (выключенный и клиент скрыты)",
|
||
len(got), total, got)
|
||
}
|
||
})
|
||
|
||
t.Run("латиница в другом регистре", func(t *testing.T) {
|
||
got, total, err := repo.SearchByRole(ctx, "worker", "PETR-UPPER", false, Page{Limit: MaxLimit})
|
||
if err != nil {
|
||
t.Fatalf("search: %v", err)
|
||
}
|
||
if total != 1 || len(got) != 1 || got[0].Name != "Petr Upper" {
|
||
t.Errorf("got %d/%d %+v, want Petr Upper", len(got), total, got)
|
||
}
|
||
})
|
||
|
||
t.Run("процент в запросе ищется буквально", func(t *testing.T) {
|
||
got, total, err := repo.SearchByRole(ctx, "worker", "100%", false, Page{Limit: MaxLimit})
|
||
if err != nil {
|
||
t.Fatalf("search: %v", err)
|
||
}
|
||
// Без экранирования шаблон %100%% совпал бы со всеми подряд.
|
||
if total != 1 || len(got) != 1 || got[0].Name != "Мастер 100%" {
|
||
t.Errorf("got %d/%d %+v, want «Мастер 100%%»", len(got), total, got)
|
||
}
|
||
})
|
||
|
||
t.Run("пустое окно считает total", func(t *testing.T) {
|
||
got, total, err := repo.SearchByRole(ctx, "worker", "петр", false, Page{Limit: MaxLimit, Offset: 100})
|
||
if err != nil {
|
||
t.Fatalf("search: %v", err)
|
||
}
|
||
if len(got) != 0 || total != 1 {
|
||
t.Errorf("got %d строк total=%d, want 0 строк total=1", len(got), total)
|
||
}
|
||
})
|
||
|
||
t.Run("ничего не найдено", func(t *testing.T) {
|
||
got, total, err := repo.SearchByRole(ctx, "worker", "нет-такого-мастера", false, Page{Limit: MaxLimit})
|
||
if err != nil {
|
||
t.Fatalf("search: %v", err)
|
||
}
|
||
if len(got) != 0 || total != 0 {
|
||
t.Errorf("got %d строк total=%d, want пусто", len(got), total)
|
||
}
|
||
})
|
||
}
|
||
|
||
func TestProfileSetActive(t *testing.T) {
|
||
q, ctx := newTestQuerier(t)
|
||
repo := NewProfileRepo(q)
|
||
|
||
p := createProfile(t, ctx, q, "worker")
|
||
|
||
if err := repo.SetActive(ctx, p.ID, false); err != nil {
|
||
t.Fatalf("deactivate: %v", err)
|
||
}
|
||
got, err := repo.GetByID(ctx, p.ID)
|
||
if err != nil {
|
||
t.Fatalf("get: %v", err)
|
||
}
|
||
if got.IsActive {
|
||
t.Error("is_active = true after deactivation")
|
||
}
|
||
|
||
if err := repo.SetActive(ctx, p.ID, true); err != nil {
|
||
t.Fatalf("activate: %v", err)
|
||
}
|
||
if got, _ := repo.GetByID(ctx, p.ID); !got.IsActive {
|
||
t.Error("is_active = false after activation")
|
||
}
|
||
|
||
if err := repo.SetActive(ctx, uuid.NewString(), false); !errors.Is(err, ErrNotFound) {
|
||
t.Errorf("unknown id: err = %v, want ErrNotFound", err)
|
||
}
|
||
}
|
||
|
||
// --- Удаление профиля и клиентское отключение ---
|
||
|
||
// TestDeleteProfileRules — «чистый» профиль удаляется, причём его
|
||
// refresh-сессии уходят каскадом (миграция 0003). Профиль, на который
|
||
// ссылаются проекты или участия (FK без каскада), БД не отдаёт:
|
||
// ErrUserHasData → 409 «отключите вместо удаления». Несуществующий id —
|
||
// ErrNotFound (404).
|
||
func TestDeleteProfileRules(t *testing.T) {
|
||
q, ctx := newTestQuerier(t)
|
||
profiles := NewProfileRepo(q)
|
||
projects := NewProjectRepo(q)
|
||
tokens := NewRefreshTokenRepo(q)
|
||
|
||
// Чистый: сам профиль + одна живая сессия — уходят обе строки.
|
||
clean := createProfile(t, ctx, q, "worker")
|
||
hash := "hash-" + uuid.NewString()
|
||
if err := tokens.Create(ctx, uuid.NewString(), clean.ID, hash,
|
||
time.Now().Add(time.Hour), "ua", "127.0.0.1"); err != nil {
|
||
t.Fatalf("create refresh token: %v", err)
|
||
}
|
||
if err := profiles.DeleteProfile(ctx, clean.ID); err != nil {
|
||
t.Fatalf("clean delete: %v", err)
|
||
}
|
||
if _, err := profiles.GetByID(ctx, clean.ID); !errors.Is(err, ErrNotFound) {
|
||
t.Errorf("после удаления: err = %v, want ErrNotFound", err)
|
||
}
|
||
if _, err := tokens.GetByHash(ctx, hash); !errors.Is(err, ErrNotFound) {
|
||
t.Errorf("сессия удалённого: err = %v, want ErrNotFound (каскад)", err)
|
||
}
|
||
|
||
if err := profiles.DeleteProfile(ctx, uuid.NewString()); !errors.Is(err, ErrNotFound) {
|
||
t.Errorf("несуществующий id: err = %v, want ErrNotFound", err)
|
||
}
|
||
|
||
// Клиент с проектом: FK без каскада не даёт стереть владельца.
|
||
owner := createProfile(t, ctx, q, "client")
|
||
p := &models.Project{ClientID: owner.ID, Name: "ЖК Отказ", IsActive: true}
|
||
if err := projects.CreateProject(ctx, p); err != nil {
|
||
t.Fatalf("create project: %v", err)
|
||
}
|
||
expectError(t, ctx, q, "delete client with project",
|
||
func() error { return profiles.DeleteProfile(ctx, owner.ID) }, ErrUserHasData)
|
||
|
||
// Мастер с активным членством — тоже история, а не «чистый» аккаунт.
|
||
worker := createProfile(t, ctx, q, "worker")
|
||
if _, err := projects.InviteWorker(ctx, p.ID, worker.ID, owner.ID); err != nil {
|
||
t.Fatalf("invite worker: %v", err)
|
||
}
|
||
if err := projects.AcceptInvitation(ctx, p.ID, worker.ID); err != nil {
|
||
t.Fatalf("accept invitation: %v", err)
|
||
}
|
||
expectError(t, ctx, q, "delete worker with membership",
|
||
func() error { return profiles.DeleteProfile(ctx, worker.ID) }, ErrUserHasData)
|
||
}
|
||
|
||
// TestSetActiveWorkerForClientRules — правило клиента целиком в SQL:
|
||
// включить/выключить можно только мастера, активного участника своих
|
||
// проектов. До принятия приглашения мастер ещё «не свой»; чужой мастер,
|
||
// другой клиент и несуществующий id неразличимы — все ErrNotFound.
|
||
func TestSetActiveWorkerForClientRules(t *testing.T) {
|
||
q, ctx := newTestQuerier(t)
|
||
profiles := NewProfileRepo(q)
|
||
projects := NewProjectRepo(q)
|
||
|
||
owner := createProfile(t, ctx, q, "client")
|
||
stranger := createProfile(t, ctx, q, "client")
|
||
worker := createProfile(t, ctx, q, "worker")
|
||
bystander := createProfile(t, ctx, q, "worker")
|
||
|
||
p := &models.Project{ClientID: owner.ID, Name: "ЖК Правило", IsActive: true}
|
||
if err := projects.CreateProject(ctx, p); err != nil {
|
||
t.Fatalf("create project: %v", err)
|
||
}
|
||
if _, err := projects.InviteWorker(ctx, p.ID, worker.ID, owner.ID); err != nil {
|
||
t.Fatalf("invite worker: %v", err)
|
||
}
|
||
|
||
// Приглашение не принято — активного членства нет, трогать пока нечего.
|
||
if err := profiles.SetActiveWorkerForClient(ctx, worker.ID, owner.ID, false); !errors.Is(err, ErrNotFound) {
|
||
t.Errorf("pending invitation: err = %v, want ErrNotFound", err)
|
||
}
|
||
if err := projects.AcceptInvitation(ctx, p.ID, worker.ID); err != nil {
|
||
t.Fatalf("accept invitation: %v", err)
|
||
}
|
||
|
||
// Свой мастер: выключается, повторный флаг возвращает — включается.
|
||
if err := profiles.SetActiveWorkerForClient(ctx, worker.ID, owner.ID, false); err != nil {
|
||
t.Fatalf("deactivate own worker: %v", err)
|
||
}
|
||
if got, err := profiles.GetByID(ctx, worker.ID); err != nil || got.IsActive {
|
||
t.Errorf("после выключения: is_active=%v err=%v, want false/nil", got.IsActive, err)
|
||
}
|
||
if err := profiles.SetActiveWorkerForClient(ctx, worker.ID, owner.ID, true); err != nil {
|
||
t.Fatalf("reactivate own worker: %v", err)
|
||
}
|
||
if got, err := profiles.GetByID(ctx, worker.ID); err != nil || !got.IsActive {
|
||
t.Errorf("после включения: is_active=%v err=%v, want true/nil", got.IsActive, err)
|
||
}
|
||
|
||
// Созданный мной, но ещё не приглашённый: гвард «свой» ровно тот же,
|
||
// что в списке ListWorkersForClient — кнопка в его строке не тупиковая.
|
||
created := &models.Profile{
|
||
ID: uuid.NewString(), Email: uuid.NewString() + "@example.test",
|
||
PasswordHash: "hash", Name: "created-worker", Role: "worker", IsActive: true,
|
||
}
|
||
if err := profiles.CreateBy(ctx, created, owner.ID); err != nil {
|
||
t.Fatalf("create by owner: %v", err)
|
||
}
|
||
if err := profiles.SetActiveWorkerForClient(ctx, created.ID, owner.ID, false); err != nil {
|
||
t.Fatalf("deactivate created (not member) worker: %v", err)
|
||
}
|
||
if err := profiles.SetActiveWorkerForClient(ctx, created.ID, stranger.ID, false); !errors.Is(err, ErrNotFound) {
|
||
t.Errorf("чужой клиент жмёт созданного: err = %v, want ErrNotFound", err)
|
||
}
|
||
if err := profiles.SetActiveWorkerForClient(ctx, created.ID, owner.ID, true); err != nil {
|
||
t.Fatalf("reactivate created worker: %v", err)
|
||
}
|
||
|
||
// Границы: чужой мастер, другой клиент, чужая роль, несуществующий id.
|
||
for _, tc := range []struct {
|
||
label, target, client string
|
||
}{
|
||
{"мастер вне проектов", bystander.ID, owner.ID},
|
||
{"чужой клиент жмёт нашего мастера", worker.ID, stranger.ID},
|
||
{"клиент жмёт профиль клиента", stranger.ID, owner.ID},
|
||
{"несуществующий id", uuid.NewString(), owner.ID},
|
||
} {
|
||
if err := profiles.SetActiveWorkerForClient(ctx, tc.target, tc.client, false); !errors.Is(err, ErrNotFound) {
|
||
t.Errorf("%s: err = %v, want ErrNotFound", tc.label, err)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestDeleteWorkerForClientRules — клиентское удаление мастера целиком
|
||
// в SQL-транзакции: граница «состоит в моём проекте» (чужой мастер и
|
||
// несуществующий id неразличимы), членства удаляются только в моих
|
||
// проектах, а FK-отказ от чужих проектов откатывает всё: профиль и все
|
||
// членства остаются на месте → ErrUserHasData (409 «отключите вместо
|
||
// удаления»).
|
||
func TestDeleteWorkerForClientRules(t *testing.T) {
|
||
q, ctx := newTestQuerier(t)
|
||
profiles := NewProfileRepo(q)
|
||
projects := NewProjectRepo(q)
|
||
|
||
owner := createProfile(t, ctx, q, "client")
|
||
stranger := createProfile(t, ctx, q, "client")
|
||
bystander := createProfile(t, ctx, q, "worker")
|
||
wInvite := createProfile(t, ctx, q, "worker")
|
||
wBusy := createProfile(t, ctx, q, "worker")
|
||
|
||
memberships := func(workerID string) int {
|
||
t.Helper()
|
||
var n int
|
||
if err := q.QueryRow(ctx,
|
||
`SELECT COUNT(*) FROM project_workers WHERE worker_id = $1`,
|
||
workerID).Scan(&n); err != nil {
|
||
t.Fatalf("count memberships: %v", err)
|
||
}
|
||
return n
|
||
}
|
||
|
||
p := &models.Project{ClientID: owner.ID, Name: "ЖК Удаление", IsActive: true}
|
||
if err := projects.CreateProject(ctx, p); err != nil {
|
||
t.Fatalf("create project: %v", err)
|
||
}
|
||
|
||
// Границы до членств: чужой и несуществующий неразличимы.
|
||
for _, tc := range []struct{ label, target, client string }{
|
||
{"несуществующий id", uuid.NewString(), owner.ID},
|
||
{"мастер вне проектов", bystander.ID, owner.ID},
|
||
{"клиент удаляет профиль клиента", stranger.ID, owner.ID},
|
||
} {
|
||
if err := profiles.DeleteWorkerForClient(ctx, tc.target, tc.client); !errors.Is(err, ErrNotFound) {
|
||
t.Errorf("%s: err = %v, want ErrNotFound", tc.label, err)
|
||
}
|
||
}
|
||
|
||
// Приглашение не принято — но членство уже есть, значит «свой»:
|
||
// аккаунт и членство уходят одной транзакцией.
|
||
if _, err := projects.InviteWorker(ctx, p.ID, wInvite.ID, owner.ID); err != nil {
|
||
t.Fatalf("invite worker: %v", err)
|
||
}
|
||
if err := profiles.DeleteWorkerForClient(ctx, wInvite.ID, stranger.ID); !errors.Is(err, ErrNotFound) {
|
||
t.Errorf("чужой клиент: err = %v, want ErrNotFound", err)
|
||
}
|
||
if err := profiles.DeleteWorkerForClient(ctx, wInvite.ID, owner.ID); err != nil {
|
||
t.Fatalf("delete own invited worker: %v", err)
|
||
}
|
||
if _, err := profiles.GetByID(ctx, wInvite.ID); !errors.Is(err, ErrNotFound) {
|
||
t.Errorf("после удаления: err = %v, want ErrNotFound", err)
|
||
}
|
||
if n := memberships(wInvite.ID); n != 0 {
|
||
t.Errorf("членство удалённого: %d строк, want 0", n)
|
||
}
|
||
|
||
// Созданный мной, но ещё не приглашённый: «свой» — удаляется
|
||
// (членств нет, шаг 2 пустой), чужой клиент — 404.
|
||
created := &models.Profile{
|
||
ID: uuid.NewString(), Email: uuid.NewString() + "@example.test",
|
||
PasswordHash: "hash", Name: "created-worker", Role: "worker", IsActive: true,
|
||
}
|
||
if err := profiles.CreateBy(ctx, created, owner.ID); err != nil {
|
||
t.Fatalf("create by owner: %v", err)
|
||
}
|
||
if err := profiles.DeleteWorkerForClient(ctx, created.ID, stranger.ID); !errors.Is(err, ErrNotFound) {
|
||
t.Errorf("чужой клиент удаляет созданного: err = %v, want ErrNotFound", err)
|
||
}
|
||
if err := profiles.DeleteWorkerForClient(ctx, created.ID, owner.ID); err != nil {
|
||
t.Fatalf("delete created (not member) worker: %v", err)
|
||
}
|
||
if _, err := profiles.GetByID(ctx, created.ID); !errors.Is(err, ErrNotFound) {
|
||
t.Errorf("после удаления созданного: err = %v, want ErrNotFound", err)
|
||
}
|
||
|
||
// Мастер состоит и в моём, и в чужом проекте: шаг 2 убирает моё
|
||
// членство, но FK от чужого даёт 23503 → откат всей транзакции —
|
||
// профиль и оба членства остаются.
|
||
if _, err := projects.InviteWorker(ctx, p.ID, wBusy.ID, owner.ID); err != nil {
|
||
t.Fatalf("invite worker (mine): %v", err)
|
||
}
|
||
if err := projects.AcceptInvitation(ctx, p.ID, wBusy.ID); err != nil {
|
||
t.Fatalf("accept invitation: %v", err)
|
||
}
|
||
sp := &models.Project{ClientID: stranger.ID, Name: "Чужой ЖК", IsActive: true}
|
||
if err := projects.CreateProject(ctx, sp); err != nil {
|
||
t.Fatalf("create stranger project: %v", err)
|
||
}
|
||
if _, err := projects.InviteWorker(ctx, sp.ID, wBusy.ID, stranger.ID); err != nil {
|
||
t.Fatalf("invite worker (foreign): %v", err)
|
||
}
|
||
|
||
expectError(t, ctx, q, "delete worker with foreign membership",
|
||
func() error { return profiles.DeleteWorkerForClient(ctx, wBusy.ID, owner.ID) }, ErrUserHasData)
|
||
|
||
if _, err := profiles.GetByID(ctx, wBusy.ID); err != nil {
|
||
t.Errorf("профиль после отката: err = %v, want существующий", err)
|
||
}
|
||
if n := memberships(wBusy.ID); n != 2 {
|
||
t.Errorf("членств после отката: %d, want 2 (моё и чужое)", n)
|
||
}
|
||
|
||
// Уже удалённый — тот же 404, что и чужой.
|
||
if err := profiles.DeleteWorkerForClient(ctx, wInvite.ID, owner.ID); !errors.Is(err, ErrNotFound) {
|
||
t.Errorf("повторное удаление: err = %v, want ErrNotFound", err)
|
||
}
|
||
}
|
||
|
||
// TestListByRoleIncludeInactive — include_inactive показывает и
|
||
// выключенных (нужно админу: кнопка «Включить» после отключения не
|
||
// должна быть тупиком); по умолчанию — только активные.
|
||
func TestListByRoleIncludeInactive(t *testing.T) {
|
||
q, ctx := newTestQuerier(t)
|
||
repo := NewProfileRepo(q)
|
||
|
||
active := createProfile(t, ctx, q, "worker")
|
||
off := createProfile(t, ctx, q, "worker")
|
||
if err := repo.SetActive(ctx, off.ID, false); err != nil {
|
||
t.Fatalf("deactivate: %v", err)
|
||
}
|
||
|
||
// В базе есть и другие мастера (посев), поэтому проверяем конкретные
|
||
// id на страницах, а не абсолютные счётчики.
|
||
got, _, err := repo.ListByRole(ctx, "worker", false, Page{Limit: MaxLimit})
|
||
if err != nil {
|
||
t.Fatalf("list active only: %v", err)
|
||
}
|
||
if containsID(got, off.ID) {
|
||
t.Error("без флага выключенный мастер не должен попадать в список")
|
||
}
|
||
if !containsID(got, active.ID) {
|
||
t.Error("активный мастер пропал из списка без флага")
|
||
}
|
||
|
||
got, _, err = repo.ListByRole(ctx, "worker", true, Page{Limit: MaxLimit})
|
||
if err != nil {
|
||
t.Fatalf("list with inactive: %v", err)
|
||
}
|
||
if !containsID(got, active.ID) || !containsID(got, off.ID) {
|
||
t.Error("с флагом должны быть оба: активный и выключенный")
|
||
}
|
||
}
|
||
|
||
// containsID — есть ли профиль с данным id на странице.
|
||
func containsID(profiles []models.Profile, id string) bool {
|
||
for _, p := range profiles {
|
||
if p.ID == id {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// TestRefreshTokens — жизнь сессии: выпуск, поиск по хешу, отзыв одной,
|
||
// отзыв всех и чистка протухших.
|
||
func TestRefreshTokens(t *testing.T) {
|
||
q, ctx := newTestQuerier(t)
|
||
repo := NewRefreshTokenRepo(q)
|
||
profile := createProfile(t, ctx, q, "client")
|
||
|
||
hash := uuid.NewString // «хеш» — достаточно уникальной строки
|
||
|
||
t.Run("выпуск и поиск по хешу", func(t *testing.T) {
|
||
h := hash()
|
||
if err := repo.Create(ctx, uuid.NewString(), profile.ID, h,
|
||
time.Now().Add(time.Hour), "test-agent", "127.0.0.1"); err != nil {
|
||
t.Fatalf("create: %v", err)
|
||
}
|
||
|
||
got, err := repo.GetByHash(ctx, h)
|
||
if err != nil {
|
||
t.Fatalf("get by hash: %v", err)
|
||
}
|
||
if got.UserID != profile.ID {
|
||
t.Errorf("user_id = %s, want %s", got.UserID, profile.ID)
|
||
}
|
||
if got.RevokedAt != nil {
|
||
t.Error("fresh token is revoked")
|
||
}
|
||
if got.UserAgent != "test-agent" || got.IP != "127.0.0.1" {
|
||
t.Errorf("agent=%q ip=%q", got.UserAgent, got.IP)
|
||
}
|
||
|
||
if _, err := repo.GetByHash(ctx, hash()); !errors.Is(err, ErrNotFound) {
|
||
t.Errorf("unknown hash: err = %v, want ErrNotFound", err)
|
||
}
|
||
})
|
||
|
||
t.Run("отзыв идемпотентен", func(t *testing.T) {
|
||
p := createProfile(t, ctx, q, "client")
|
||
h := hash()
|
||
if err := repo.Create(ctx, uuid.NewString(), p.ID, h,
|
||
time.Now().Add(time.Hour), "", ""); err != nil {
|
||
t.Fatalf("create: %v", err)
|
||
}
|
||
|
||
ok, err := repo.RevokeByHash(ctx, h)
|
||
if err != nil || !ok {
|
||
t.Fatalf("first revoke: ok=%v err=%v", ok, err)
|
||
}
|
||
ok, err = repo.RevokeByHash(ctx, h)
|
||
if err != nil || ok {
|
||
t.Errorf("second revoke: ok=%v err=%v, want false,nil", ok, err)
|
||
}
|
||
|
||
got, err := repo.GetByHash(ctx, h)
|
||
if err != nil {
|
||
t.Fatalf("get: %v", err)
|
||
}
|
||
if got.RevokedAt == nil {
|
||
t.Error("revoked_at not set")
|
||
}
|
||
})
|
||
|
||
t.Run("revoked_at не трогает активные", func(t *testing.T) {
|
||
// Своя пара «клиент — сессии», чтобы прошлые подтесты не влияли
|
||
// на счётчик: RevokeAllForUser считает только активные записи.
|
||
p := createProfile(t, ctx, q, "client")
|
||
live := hash()
|
||
if err := repo.Create(ctx, uuid.NewString(), p.ID, live,
|
||
time.Now().Add(time.Hour), "", ""); err != nil {
|
||
t.Fatalf("create live: %v", err)
|
||
}
|
||
dead := hash()
|
||
if err := repo.Create(ctx, uuid.NewString(), p.ID, dead,
|
||
time.Now().Add(time.Hour), "", ""); err != nil {
|
||
t.Fatalf("create dead: %v", err)
|
||
}
|
||
if _, err := repo.RevokeByHash(ctx, dead); err != nil {
|
||
t.Fatalf("revoke: %v", err)
|
||
}
|
||
|
||
n, err := repo.RevokeAllForUser(ctx, p.ID)
|
||
if err != nil {
|
||
t.Fatalf("revoke all: %v", err)
|
||
}
|
||
if n != 1 {
|
||
t.Errorf("revoked %d session(s), want 1 (уже отозванная не считается)", n)
|
||
}
|
||
if got, _ := repo.GetByHash(ctx, live); got.RevokedAt == nil {
|
||
t.Error("live token not revoked by logout-all")
|
||
}
|
||
})
|
||
|
||
t.Run("чистка протухших", func(t *testing.T) {
|
||
expired := hash()
|
||
if err := repo.Create(ctx, uuid.NewString(), profile.ID, expired,
|
||
time.Now().Add(-time.Hour), "", ""); err != nil {
|
||
t.Fatalf("create expired: %v", err)
|
||
}
|
||
fresh := hash()
|
||
if err := repo.Create(ctx, uuid.NewString(), profile.ID, fresh,
|
||
time.Now().Add(time.Hour), "", ""); err != nil {
|
||
t.Fatalf("create fresh: %v", err)
|
||
}
|
||
|
||
removed, err := repo.DeleteExpired(ctx)
|
||
if err != nil {
|
||
t.Fatalf("delete expired: %v", err)
|
||
}
|
||
if removed < 1 {
|
||
t.Errorf("removed %d, want at least the expired one", removed)
|
||
}
|
||
if _, err := repo.GetByHash(ctx, expired); !errors.Is(err, ErrNotFound) {
|
||
t.Errorf("expired token survived: err = %v", err)
|
||
}
|
||
if _, err := repo.GetByHash(ctx, fresh); err != nil {
|
||
t.Errorf("fresh token removed: %v", err)
|
||
}
|
||
})
|
||
}
|
||
|
||
// TestUserConfirmationRules — матрица подтверждений (миграция 0010):
|
||
// - вставка без актора (Create) — pending;
|
||
// - создание админом (CreateBy) — подтверждение сразу, тем же INSERT;
|
||
// - создание клиентом — pending, ждёт подтверждения этим клиентом;
|
||
// - подтверждает админ (любого) или клиент (только созданного им мастера);
|
||
// - повтор — ErrAlreadyConfirmed, чужой/неподходящий/неизвестный — 404.
|
||
func TestUserConfirmationRules(t *testing.T) {
|
||
q, ctx := newTestQuerier(t)
|
||
repo := NewProfileRepo(q)
|
||
|
||
mkProfile := func(role string) *models.Profile {
|
||
return &models.Profile{
|
||
ID: uuid.NewString(),
|
||
Email: uuid.NewString() + "@example.test",
|
||
PasswordHash: "hash",
|
||
Name: role + "-pending",
|
||
Role: role,
|
||
IsActive: true,
|
||
}
|
||
}
|
||
|
||
admin := createProfile(t, ctx, q, "admin")
|
||
client := createProfile(t, ctx, q, "client")
|
||
otherClient := createProfile(t, ctx, q, "client")
|
||
|
||
// Вставка без актора — подтверждения нет.
|
||
noActor := mkProfile("client")
|
||
if err := repo.Create(ctx, noActor); err != nil {
|
||
t.Fatalf("create without actor: %v", err)
|
||
}
|
||
if noActor.ConfirmedAt != nil {
|
||
t.Error("create without actor must be pending")
|
||
}
|
||
|
||
// Создание админом = подтверждение сразу («создатель и есть подтверждающий»).
|
||
byAdmin := mkProfile("worker")
|
||
if err := repo.CreateBy(ctx, byAdmin, admin.ID); err != nil {
|
||
t.Fatalf("create by admin: %v", err)
|
||
}
|
||
if byAdmin.ConfirmedAt == nil {
|
||
t.Error("admin-created worker must be confirmed at creation")
|
||
}
|
||
|
||
// Создание клиентом — pending: клиент подтвердит сам.
|
||
byClient := mkProfile("worker")
|
||
if err := repo.CreateBy(ctx, byClient, client.ID); err != nil {
|
||
t.Fatalf("create by client: %v", err)
|
||
}
|
||
if byClient.ConfirmedAt != nil {
|
||
t.Error("client-created worker must be pending")
|
||
}
|
||
|
||
// Админ подтверждает профиль без создателя, confirmed_by = админ.
|
||
if err := repo.ConfirmUser(ctx, noActor.ID, admin.ID); err != nil {
|
||
t.Fatalf("admin confirms: %v", err)
|
||
}
|
||
got, err := repo.GetByID(ctx, noActor.ID)
|
||
if err != nil {
|
||
t.Fatalf("reread: %v", err)
|
||
}
|
||
if got.ConfirmedAt == nil {
|
||
t.Error("must be confirmed after ConfirmUser")
|
||
}
|
||
var confirmedBy string
|
||
if err := q.QueryRow(ctx,
|
||
`SELECT confirmed_by FROM profiles WHERE id = $1`, noActor.ID,
|
||
).Scan(&confirmedBy); err != nil {
|
||
t.Fatalf("read confirmed_by: %v", err)
|
||
}
|
||
if confirmedBy != admin.ID {
|
||
t.Errorf("confirmed_by = %s, want %s", confirmedBy, admin.ID)
|
||
}
|
||
|
||
// Повторное подтверждение — 409.
|
||
expectError(t, ctx, q, "confirm twice",
|
||
func() error { return repo.ConfirmUser(ctx, noActor.ID, admin.ID) },
|
||
ErrAlreadyConfirmed)
|
||
|
||
// Клиент подтверждает созданного им мастера.
|
||
if err := repo.ConfirmUser(ctx, byClient.ID, client.ID); err != nil {
|
||
t.Fatalf("client confirms own worker: %v", err)
|
||
}
|
||
|
||
// Чужого (не созданного этим клиентом) мастера клиент не подтверждает.
|
||
foreign := mkProfile("worker")
|
||
if err := repo.Create(ctx, foreign); err != nil {
|
||
t.Fatalf("foreign worker: %v", err)
|
||
}
|
||
expectError(t, ctx, q, "client confirms foreign worker",
|
||
func() error { return repo.ConfirmUser(ctx, foreign.ID, client.ID) },
|
||
ErrNotFound)
|
||
|
||
// Другого клиента клиент не подтверждает (роль не worker → 404).
|
||
expectError(t, ctx, q, "client confirms another client",
|
||
func() error { return repo.ConfirmUser(ctx, otherClient.ID, client.ID) },
|
||
ErrNotFound)
|
||
|
||
// Мастер не подтверждает никого — 404.
|
||
expectError(t, ctx, q, "worker confirms someone",
|
||
func() error { return repo.ConfirmUser(ctx, foreign.ID, byClient.ID) },
|
||
ErrNotFound)
|
||
|
||
// Несуществующий id — 404, а не ошибка типа или 409.
|
||
expectError(t, ctx, q, "confirm unknown id",
|
||
func() error { return repo.ConfirmUser(ctx, uuid.NewString(), admin.ID) },
|
||
ErrNotFound)
|
||
}
|