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) } }