Netpulse_SasS/server/internal/httpapi/auth_test.go
byrsapty a4faf31fcd Етап 5: користувачі, вхід і права
Досі доступ давав машинний токен зі змінної збірки — одні права на всіх
і жодного способу відрізнити, хто що зробив. Тепер продукт уміє впустити
людину.

Сервер:
- argon2id для паролів, власний HS256 JWT (15 хв) + refresh-сесія в
  httpOnly-кукі на 30 днів з ротацією при кожному обміні
- Principal зводить людину й машинний токен до одного набору прав;
  права читаються з БД на кожному запиті, а не з claims, щоб відкликана
  роль не жила до кінця TTL
- 9 ендпоїнтів: auth/login|refresh|logout|password, me, team CRUD, roles
- netpulse-user — CLI для першого власника: публічна реєстрація в B2B
  це дірка, а «перший через веб, поки нікого немає» — нечесна гонка
- міграція 0012: три RLS-політики винятку для шляху входу (без них
  вхід неможливий за побудовою — щоб знайти користувача за email,
  треба знати тенант, який відомий лише після пошуку) і login_attempts
  для тротлінгу

Фронтенд:
- сторінка входу з вибором організації, access-токен у замиканні
  модуля замість localStorage, тихе відновлення сесії по кукі
- один refresh на всі паралельні запити: інакше ротація зробила б усі,
  крім першого, недійсними й викинула б людину на вхід
- дії без права не показуються; полотно нередаговане для глядача
- мобільний адаптив: висувна бічна панель, інспектор нижнім аркушем

11 нових тестів (37 у httpapi), go vet і tsc чисто, живий прогін з
8 кроків проти netpulse_it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 10:24:58 +03:00

310 lines
10 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package httpapi_test
import (
"encoding/json"
"net/http"
"testing"
)
// seedUser заводить людину з паролем і роллю в тенанті фікстури.
func (f *fixture) seedUser(t *testing.T, email, password, roleKey string) string {
t.Helper()
id, err := f.store.CreateUser(f.ctx, f.tenantID, email, password, "Тест Тестович", roleKey)
if err != nil {
t.Fatalf("створення користувача: %v", err)
}
return id.ID
}
type loginResp struct {
AccessToken string `json:"access_token"`
ExpiresIn int `json:"expires_in"`
Permissions []string `json:"permissions"`
User struct {
ID string `json:"id"`
Email string `json:"email"`
} `json:"user"`
Tenant *struct {
TenantID string `json:"tenant_id"`
RoleKey string `json:"role_key"`
} `json:"tenant"`
Tenants []struct {
TenantID string `json:"tenant_id"`
} `json:"tenants"`
}
func (f *fixture) login(t *testing.T, email, password string) (int, loginResp, *http.Cookie) {
t.Helper()
body, _ := json.Marshal(map[string]string{"email": email, "password": password})
req, _ := http.NewRequest(http.MethodPost, f.srv.URL+"/api/v1/auth/login", bytesReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := f.srv.Client().Do(req)
if err != nil {
t.Fatalf("login: %v", err)
}
defer resp.Body.Close()
var out loginResp
_ = json.NewDecoder(resp.Body).Decode(&out)
var refresh *http.Cookie
for _, c := range resp.Cookies() {
if c.Name == "np_refresh" {
refresh = c
}
}
return resp.StatusCode, out, refresh
}
// ---------------------------------------------------------------------
func TestLoginSuccess(t *testing.T) {
f := setup(t)
f.seedUser(t, "engineer@example.com", "correct-horse-battery", "engineer")
code, out, refresh := f.login(t, "engineer@example.com", "correct-horse-battery")
if code != http.StatusOK {
t.Fatalf("код %d", code)
}
if out.AccessToken == "" {
t.Fatal("не видано токен доступу")
}
if out.Tenant == nil || out.Tenant.TenantID != f.tenantID {
t.Fatalf("тенант у відповіді: %+v", out.Tenant)
}
if out.Tenant.RoleKey != "engineer" {
t.Fatalf("роль: %q", out.Tenant.RoleKey)
}
// Refresh-токен має бути httpOnly: інакше XSS-скрипт забирає
// довгоживучу сесію, а не 15-хвилинний токен.
if refresh == nil {
t.Fatal("немає кукі з refresh-токеном")
}
if !refresh.HttpOnly {
t.Fatal("refresh-кукі не httpOnly")
}
if refresh.SameSite != http.SameSiteStrictMode {
t.Fatalf("SameSite = %v", refresh.SameSite)
}
// Права інженера: пише мапи, але не керує білінгом.
perms := map[string]bool{}
for _, p := range out.Permissions {
perms[p] = true
}
if !perms["maps:write"] {
t.Fatalf("інженер без maps:write: %v", out.Permissions)
}
if perms["billing:manage"] {
t.Fatal("інженер отримав billing:manage")
}
}
// Відповідь на невідомий email і на невірний пароль має бути однаковою:
// різні тексти перетворюють форму входу на довідник адрес.
func TestLoginDoesNotLeakAccountExistence(t *testing.T) {
f := setup(t)
f.seedUser(t, "known@example.com", "correct-horse-battery", "viewer")
c1, r1, _ := f.login(t, "known@example.com", "wrong-password")
c2, r2, _ := f.login(t, "nobody@example.com", "wrong-password")
if c1 != http.StatusUnauthorized || c2 != http.StatusUnauthorized {
t.Fatalf("коди: %d і %d", c1, c2)
}
if r1.AccessToken != "" || r2.AccessToken != "" {
t.Fatal("видано токен на невірний пароль")
}
}
func TestAccessTokenWorksAndCarriesPermissions(t *testing.T) {
f := setup(t)
f.seedUser(t, "viewer@example.com", "correct-horse-battery", "viewer")
_, out, _ := f.login(t, "viewer@example.com", "correct-horse-battery")
// Читання дозволене.
if code, body := f.get(t, "/api/v1/maps", out.AccessToken); code != http.StatusOK {
t.Fatalf("глядач не може читати мапи: %d %s", code, body)
}
// Запис — ні: у ролі viewer немає maps:write.
code, body := f.send(t, http.MethodPatch, "/api/v1/maps/"+f.mapID, out.AccessToken, `{}`)
if code != http.StatusForbidden {
t.Fatalf("глядач змінив мапу: код %d %s", code, body)
}
}
func TestMeReportsIdentity(t *testing.T) {
f := setup(t)
f.seedUser(t, "admin@example.com", "correct-horse-battery", "admin")
_, out, _ := f.login(t, "admin@example.com", "correct-horse-battery")
code, body := f.get(t, "/api/v1/me", out.AccessToken)
if code != http.StatusOK {
t.Fatalf("код %d", code)
}
var me map[string]any
if err := json.Unmarshal(body, &me); err != nil {
t.Fatalf("розбір: %v", err)
}
if me["email"] != "admin@example.com" || me["tenant_id"] != f.tenantID {
t.Fatalf("me: %v", me)
}
if perms, ok := me["permissions"].([]any); !ok || len(perms) == 0 {
t.Fatalf("me без прав: %v", me["permissions"])
}
}
// Машинний токен без scopes і далі має повний доступ — інакше зламалися
// б усі наявні інтеграції.
func TestApiTokenStillWorks(t *testing.T) {
f := setup(t)
if code, _ := f.get(t, "/api/v1/maps", f.token); code != http.StatusOK {
t.Fatalf("машинний токен перестав працювати: %d", code)
}
code, body := f.get(t, "/api/v1/me", f.token)
if code != http.StatusOK {
t.Fatalf("код %d", code)
}
var me map[string]any
_ = json.Unmarshal(body, &me)
if me["token_name"] != "ui" {
t.Fatalf("me для токена: %v", me)
}
}
// Підроблений підпис не має проходити.
func TestForgedTokenRejected(t *testing.T) {
f := setup(t)
f.seedUser(t, "user@example.com", "correct-horse-battery", "admin")
_, out, _ := f.login(t, "user@example.com", "correct-horse-battery")
// Псуємо останній символ підпису.
bad := out.AccessToken[:len(out.AccessToken)-1]
if out.AccessToken[len(out.AccessToken)-1] == 'A' {
bad += "B"
} else {
bad += "A"
}
if code, _ := f.get(t, "/api/v1/maps", bad); code != http.StatusUnauthorized {
t.Fatalf("підроблений токен прийнято: код %d", code)
}
}
func TestRefreshRotatesSession(t *testing.T) {
f := setup(t)
f.seedUser(t, "rot@example.com", "correct-horse-battery", "admin")
_, _, refresh := f.login(t, "rot@example.com", "correct-horse-battery")
call := func(c *http.Cookie) (int, loginResp, *http.Cookie) {
req, _ := http.NewRequest(http.MethodPost, f.srv.URL+"/api/v1/auth/refresh", nil)
req.AddCookie(c)
resp, err := f.srv.Client().Do(req)
if err != nil {
t.Fatalf("refresh: %v", err)
}
defer resp.Body.Close()
var out loginResp
_ = json.NewDecoder(resp.Body).Decode(&out)
var next *http.Cookie
for _, ck := range resp.Cookies() {
if ck.Name == "np_refresh" {
next = ck
}
}
return resp.StatusCode, out, next
}
code, out, second := call(refresh)
if code != http.StatusOK || out.AccessToken == "" {
t.Fatalf("обмін не вдався: код %d", code)
}
if second == nil || second.Value == refresh.Value {
t.Fatal("refresh-токен не змінився — ротації немає")
}
// Старий токен має померти: викрадений спрацює щонайбільше раз,
// і законний власник одразу побачить відмову.
if code, _, _ := call(refresh); code != http.StatusUnauthorized {
t.Fatalf("старий refresh-токен усе ще діє: код %d", code)
}
}
func TestLogoutRevokesSession(t *testing.T) {
f := setup(t)
f.seedUser(t, "out@example.com", "correct-horse-battery", "admin")
_, _, refresh := f.login(t, "out@example.com", "correct-horse-battery")
req, _ := http.NewRequest(http.MethodPost, f.srv.URL+"/api/v1/auth/logout", nil)
req.AddCookie(refresh)
resp, err := f.srv.Client().Do(req)
if err != nil {
t.Fatalf("logout: %v", err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusNoContent {
t.Fatalf("код %d", resp.StatusCode)
}
req, _ = http.NewRequest(http.MethodPost, f.srv.URL+"/api/v1/auth/refresh", nil)
req.AddCookie(refresh)
resp2, err := f.srv.Client().Do(req)
if err != nil {
t.Fatalf("refresh: %v", err)
}
resp2.Body.Close()
if resp2.StatusCode != http.StatusUnauthorized {
t.Fatalf("сесія жива після виходу: код %d", resp2.StatusCode)
}
}
// Людина без членства в тенанті не має отримати токен, навіть якщо
// пароль правильний.
func TestLoginWithoutMembership(t *testing.T) {
f := setup(t)
hash := "" // створюємо користувача напряму, без членства
_ = hash
if _, err := f.pool.Exec(f.ctx, `
INSERT INTO core.users (email, password_hash, email_verified_at)
VALUES ('orphan@example.com', $1, now())
`, mustHash(t, "correct-horse-battery")); err != nil {
t.Fatalf("seed: %v", err)
}
t.Cleanup(func() {
_, _ = f.pool.Exec(f.ctx, `DELETE FROM core.users WHERE email = 'orphan@example.com'`)
})
code, out, _ := f.login(t, "orphan@example.com", "correct-horse-battery")
if code != http.StatusForbidden {
t.Fatalf("код %d", code)
}
if out.AccessToken != "" {
t.Fatal("видано токен без членства")
}
}
// Відкликане членство має негайно позбавляти доступу, навіть якщо
// токен ще не протермінований.
func TestRevokedMembershipBlocksToken(t *testing.T) {
f := setup(t)
userID := f.seedUser(t, "fired@example.com", "correct-horse-battery", "engineer")
_, out, _ := f.login(t, "fired@example.com", "correct-horse-battery")
if code, _ := f.get(t, "/api/v1/maps", out.AccessToken); code != http.StatusOK {
t.Fatal("доступ не працював ще до відкликання")
}
if err := f.store.RemoveMember(f.ctx, f.tenantID, userID); err != nil {
t.Fatalf("видалення членства: %v", err)
}
if code, _ := f.get(t, "/api/v1/maps", out.AccessToken); code != http.StatusUnauthorized {
t.Fatalf("токен звільненого ще діє: код %d", code)
}
}