go-git, чистий Go без cgo. Голий репозиторій на тенанта, гілка на пристрій за ідентифікатором (ім'я змінюють, історія не має від цього розсипатись), файл за іменем. Однаковий вміст нового коміту не створює. netpulse-gitsync переливає накопичену історію й відтворює втрачений репозиторій із бази — тіла конфігів там і так лежать зашифрованими. У вебі з'явився вибір версії, з якою порівнювати: сервер це вмів (?from=), інтерфейс — ні. Дорогою виправлено чотири тести, які CI запустив уперше з базою. Серед них справжня помилка: машинний токен не міг читати мапи — ACL мап отримував порожній рядок замість uuid і давав 500. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
350 lines
12 KiB
Go
350 lines
12 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)
|
||
}
|
||
}
|