Досі доступ давав машинний токен зі змінної збірки — одні права на всіх і жодного способу відрізнити, хто що зробив. Тепер продукт уміє впустити людину. Сервер: - 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>
310 lines
10 KiB
Go
310 lines
10 KiB
Go
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)
|
||
}
|
||
}
|