Netpulse_SasS/server/internal/httpapi/auth_test.go
byrsapty 96692ec9df Вхід за логіном, групи хостів і права доступу
Логін:
- core.users.username замість пошти як ідентифікатор: у мережевій
  інсталяції половина акаунтів технічні (noc, monitoring, oncall) і
  скриньки не мають узагалі. Пошта лишилась необов'язковим полем
- наявним користувачам логін виведено з пошти, збіги розведено
  суфіксом: мовчки злити admin@a.com і admin@b.com в один логін —
  це втрата акаунта, а не міграція
- сервер шукає за логіном і за поштою, тому звичка вводити email
  нікого не відхиляє

Групи (модель Zabbix):
- групи хостів і групи доступу; права read/write/deny на групу хостів
- роль каже, що вільно робити; група — над якими хостами. Інженер над
  філією та інженер над усією мережею мають однакову роль і різний
  доступ
- хто не входить у жодну групу, групами не обмежений — свідомо не
  по-заббіксівськи: там кожна нова інсталяція починається з питання
  «чому порожньо»
- заборона перемагає дозвіл, інакше її обійти додаванням у сусідню групу
- фільтр накладається в самому запиті, а не після вибірки

Хости: додавання, редагування, м'яке видалення, прив'язка до груп,
фільтр за групою в таблиці.

Мапа: створення мапи з транслітерацією slug, інспектор вузла — підпис,
значок, розмір, ширина, колір рамки, закріплення.

Виправлено за скаргами:
- перемикач вилазив на 14px за трек: у ручки не заданий left, а
  статичну позицію зсуває типове text-align: center у <button>
- сторінка правил не оновлювала «активних»: після повернення правила
  алерти піднімаються наступним тіком движка, тобто ПІСЛЯ нашого
  перечитування — бракувало підписки на живі події
- канал із секретом показувався як «секрету немає»

Живий прогін: eng у групі з read на «Доступ» бачить 2 хости замість 3,
обидва без запису, алертів 1 замість 3, редагування чужого хоста 403.

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

317 lines
11 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"
"strings"
"testing"
)
// seedUser заводить людину з паролем і роллю в тенанті фікстури.
//
// Логін виводиться з пошти, щоб тести лишались читабельними: у них
// важлива роль користувача, а не форма його ідентифікатора.
func (f *fixture) seedUser(t *testing.T, email, password, roleKey string) string {
t.Helper()
login, _, _ := strings.Cut(email, "@")
id, err := f.store.CreateUser(f.ctx, f.tenantID, login, 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"`
Username string `json:"username"`
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()
login, _, _ := strings.Cut(email, "@")
body, _ := json.Marshal(map[string]string{"login": login, "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)
}
}