Netpulse_SasS/server/internal/httpapi/auth_test.go
byrsapty f6df538020 Аудит: закрито дві найдорожчі сліпі зони — алерти й склад команди
За цілий день активної роботи в журналі не з'явилось нічого: цих
доменів у ньому просто не було. Проєкт це визнавав сам, у
AuditBlindSpots().

Тепер пишуться: правила алертів (створення/зміна/видалення й окремо
вимкнення-увімкнення), канали, драбини ескалації, правила
відповідності; додавання людини в кабінет, зміна ролі, вилучення,
правка профілю й скидання пароля.

Два рішення про зміст запису:
* вимкнення видно з НАЗВИ дії (alr.rule.disable), а не з різниці
  подробиць — питання «хто вимкнув правило, за яким приходив алерт»
  має відповідатись переліком, а не порівнянням;
* config каналу не їде в запис ВЗАГАЛІ — там не лише токен бота, а й
  адреса вебхука (доступ на запис у чужий чат) і заголовок
  Authorization. Замість нього прапорець secret_changed.

Мапи, групи й шаблони свідомо лишились у сліпих зонах: їхня правка
видима в самому об'єкті. Туди ж додано дві нові чесні межі — тригери
від шаблонів пишуться повз обробник, і пробне повідомлення в канал.

Прогнано проти справжньої бази: секрет каналу й пароль учасника в
подробиці не потрапляють.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 01:06:21 +03:00

350 lines
13 KiB
Go
Raw Permalink 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"
"time"
"github.com/netpulse/netpulse/server/internal/store"
)
// 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")
// Псуємо ПЕРШИЙ символ підпису, а не останній.
//
// Останній символ base64url несе лише 4 значущі біти з шести, а два
// зайві декодер відкидає. Тому 'A' і 'B' на останньому місці дають
// ті самі 32 байти підпису, токен лишається дійсним, і тест падає
// приблизно в кожному третьому запуску — залежно від того, який
// символ випав цього разу.
cut := strings.LastIndexByte(out.AccessToken, '.')
if cut < 0 || cut+1 >= len(out.AccessToken) {
t.Fatalf("токен без підпису: %q", out.AccessToken)
}
repl := byte('A')
if out.AccessToken[cut+1] == 'A' {
repl = 'B'
}
bad := out.AccessToken[:cut+1] + string(repl) + out.AccessToken[cut+2:]
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-токен не змінився — ротації немає")
}
// Щойно обміняний токен ще працює — і це навмисно.
//
// Дві вкладки, повторний запит після обриву й подвійний ефект React
// дають два обміни поспіль з одного браузера. Без вікна вони вбивали
// одне одного, і виглядало це як «просить логін після кожного
// оновлення сторінки».
code, _, third := call(refresh)
if code != http.StatusOK {
t.Fatalf("повторний обмін у вікні відхилено: код %d", code)
}
if third == nil || third.Value == second.Value {
t.Fatal("повторний обмін не видав нового токена")
}
// А от давній обмін — це вже не гонка, а повтор перехопленого
// токена. Відсуваємо позначку відкликання за межі вікна замість
// чекати наживо.
if _, err := f.pool.Exec(f.ctx, `
UPDATE core.sessions SET revoked_at = now() - $1::interval
WHERE revoked_at IS NOT NULL
`, (store.RotationGrace + time.Minute).String()); err != nil {
t.Fatalf("зсув часу відкликання: %v", err)
}
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)
// Логін обов'язковий: вхід за іменем користувача з'явився пізніше
// за цей тест, і колонка стала NOT NULL.
if _, err := f.pool.Exec(f.ctx, `
INSERT INTO core.users (username, email, password_hash, email_verified_at)
VALUES ('orphan', '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)
}
}