За цілий день активної роботи в журналі не з'явилось нічого: цих доменів у ньому просто не було. Проєкт це визнавав сам, у AuditBlindSpots(). Тепер пишуться: правила алертів (створення/зміна/видалення й окремо вимкнення-увімкнення), канали, драбини ескалації, правила відповідності; додавання людини в кабінет, зміна ролі, вилучення, правка профілю й скидання пароля. Два рішення про зміст запису: * вимкнення видно з НАЗВИ дії (alr.rule.disable), а не з різниці подробиць — питання «хто вимкнув правило, за яким приходив алерт» має відповідатись переліком, а не порівнянням; * config каналу не їде в запис ВЗАГАЛІ — там не лише токен бота, а й адреса вебхука (доступ на запис у чужий чат) і заголовок Authorization. Замість нього прапорець secret_changed. Мапи, групи й шаблони свідомо лишились у сліпих зонах: їхня правка видима в самому об'єкті. Туди ж додано дві нові чесні межі — тригери від шаблонів пишуться повз обробник, і пробне повідомлення в канал. Прогнано проти справжньої бази: секрет каналу й пароль учасника в подробиці не потрапляють. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
350 lines
13 KiB
Go
350 lines
13 KiB
Go
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)
|
||
}
|
||
}
|