Netpulse_SasS/server/internal/store/login_attempts_test.go
byrsapty 95a19b074d
Some checks failed
CI / agent (push) Waiting to run
CI / hygiene (push) Successful in 9s
CI / web (push) Successful in 1m11s
CI / server (push) Successful in 1m35s
CI / dbtest (push) Has been cancelled
Історія входів: 93 записи були в базі й ніде не показувались
core.login_attempts наповнювалась на кожній спробі входу й читалась
рівно в одному місці — для стримування підбору. Ні ендпойнта, ні
сторінки. На питання «хто заходив у мій моніторинг» продукт мав
відповідь у базі й не мав способу її показати.

ГОЛОВНЕ БУЛА НЕ ВЕРСТКА. У таблиці немає tenant_id — саме тому ці
події й не в аудиті. Показати «як є» означало б віддати одному
кабінету спроби входу чужих людей. Прив'язка непряма: спроба ->
користувач -> членство, трьома шляхами одночасно (user_id, username,
email) в одному JOIN LATERAL з tenant_id усередині. Саме JOIN, а не
LEFT JOIN: без збігу рядок ПРИБИРАЄТЬСЯ, а не лишається без імені.

На бойових даних це не теорія: у власника немає пошти, тож за email
не прив'язується ЖОДНА з 94 спроб. Шлях через username дає 93.

Невдала спроба з неіснуючим логіном не належить нікому: рядком не
показується, але рахується числом — сигнал «логіни перебирають»
лишається, чужа людина в чужий кабінет не потрапляє.

Ізоляцію перевірено проти справжньої бази (TestLoginAttemptsTenantIsolation).

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

413 lines
18 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 store
import (
"context"
"os"
"regexp"
"strconv"
"strings"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
// Відсіювання чужого в історії входів.
//
// ЧОМУ ЦЕЙ ФАЙЛ ІСНУЄ. У core.login_attempts немає tenant_id, тож
// ізоляція кабінетів тут не тримається ні на RLS, ні на предикаті по
// стовпцю — вона тримається на одному JOIN LATERAL у
// loginAttemptsFrom. Помилка в ньому не падає й не порожнить екран:
// сторінка малюється, рядки на місці, просто серед них чужі — з
// логінами, поштами й адресами людей із сусіднього кабінету. Побачити
// це можна лише знаючи, кого в кабінеті бути не може.
//
// Тому перевірок дві, і вони різні за силою.
//
// 1. Проти рядка запиту — працює завжди, зокрема на машині без
// Postgres. Ловить найімовірнішу поломку: умову прибрали або вона
// зникла для якоїсь комбінації фільтрів.
// 2. Проти БАЗИ — доводить не наявність рядка в SQL, а результат.
// Мовчки пропускається без NETPULSE_TEST_DSN (scripts/dbtest.sh).
// ---------------------------------------------------------------------
// 1. Умова кабінету є завжди
// ---------------------------------------------------------------------
func loginFilterCases() map[string]LoginAttemptFilter {
now := time.Now().UTC()
return map[string]LoginAttemptFilter{
"порожній": {},
"період": {From: now.Add(-24 * time.Hour), To: now},
"лише невдалі": {Outcome: "failed"},
"лише вдалі": {Outcome: "success"},
"причини": {Reasons: []string{"bad_password", "no_user"}},
"люди": {UserIDs: []string{"9b8e1737-5661-4b9c-b14e-ed8cd382145b"}},
"підмережа": {IP: "10.20.0.0/24"},
"пошук": {Query: "adm1n"},
"курсор": {
Cursor: encodeAuditCursor(now, "9b8e1737-5661-4b9c-b14e-ed8cd382145b"),
},
"усе разом": {
From: now.Add(-24 * time.Hour),
To: now,
Outcome: "failed",
Reasons: []string{"bad_password"},
UserIDs: []string{"9b8e1737-5661-4b9c-b14e-ed8cd382145b"},
IP: "10.20.0.5",
Query: "adm1n",
Cursor: encodeAuditCursor(now, "9b8e1737-5661-4b9c-b14e-ed8cd382145b"),
},
}
}
// Головна перевірка файла: жодна комбінація фільтрів не збирає запит
// без прив'язки до кабінету.
//
// Перевіряється не «десь є слово tenant», а три речі одночасно: умова
// стоїть на $1, вона всередині LATERAL (тобто відсікає рядок, а не
// просто добирає ім'я), і сам LATERAL приєднаний як JOIN, а не LEFT
// JOIN. Останнє — найтихіша з можливих поломок: одне слово перетворює
// відсіювання на «добери ім'я, якщо знайдеш», і вибірка починає
// віддавати ЧУЖІ спроби з порожньою колонкою «хто».
func TestLoginAttemptsAlwaysScopedToTenant(t *testing.T) {
for name, f := range loginFilterCases() {
t.Run(name, func(t *testing.T) {
from, args, err := loginAttemptsFrom("00000000-0000-0000-0000-000000000001", f)
if err != nil {
t.Fatalf("loginAttemptsFrom: %v", err)
}
if !strings.Contains(from, "m.tenant_id = $1") {
t.Fatalf("немає предикату кабінету:\n%s", from)
}
if !strings.Contains(from, "JOIN LATERAL") {
t.Fatalf("прив'язка більше не через LATERAL:\n%s", from)
}
if strings.Contains(from, "LEFT JOIN LATERAL") {
t.Fatalf("LEFT JOIN LATERAL не відсікає чужі рядки — лише не добирає ім'я:\n%s", from)
}
// Предикат мусить стояти ВСЕРЕДИНІ підзапиту, інакше він
// нічого не обмежує.
open := strings.Index(from, "JOIN LATERAL")
closeAt := strings.Index(from, ") usr")
tenantAt := strings.Index(from, "m.tenant_id = $1")
if !(open < tenantAt && tenantAt < closeAt) {
t.Fatalf("предикат кабінету поза LATERAL:\n%s", from)
}
if args[0] != "00000000-0000-0000-0000-000000000001" {
t.Fatalf("$1 — не кабінет, а %v", args[0])
}
})
}
}
// Нумерація плейсхолдерів — те, що ламається мовчки.
//
// Помилка на одиницю тут не падає: запит виконується, просто порівнює
// не ту колонку не з тим значенням. На сторінці, яку відкривають з
// питанням «чи нас ламають», це неправильна відповідь із виглядом
// правильної.
func TestLoginAttemptsPlaceholdersMatchArgs(t *testing.T) {
ph := regexp.MustCompile(`\$(\d+)`)
for name, f := range loginFilterCases() {
t.Run(name, func(t *testing.T) {
from, args, err := loginAttemptsFrom("00000000-0000-0000-0000-000000000001", f)
if err != nil {
t.Fatalf("loginAttemptsFrom: %v", err)
}
seen := map[int]bool{}
maxN := 0
for _, m := range ph.FindAllStringSubmatch(from, -1) {
n, _ := strconv.Atoi(m[1])
seen[n] = true
if n > maxN {
maxN = n
}
}
if maxN != len(args) {
t.Fatalf("найбільший плейсхолдер $%d, а аргументів %d\n%s", maxN, len(args), from)
}
for i := 1; i <= len(args); i++ {
if !seen[i] {
t.Fatalf("аргумент $%d не використано\n%s", i, from)
}
}
})
}
}
// Порожній кабінет — відмова, а не «показати все».
//
// Виклик без tenantID сьогодні дав би запит, у якому порожній рядок не
// збігається ні з чим, тобто порожню відповідь — «на щастя». Умова, що
// відділяє кабінети, не має триматись на щасті.
func TestLoginAttemptsRejectsEmptyTenant(t *testing.T) {
for _, id := range []string{"", " "} {
if _, _, err := loginAttemptsFrom(id, LoginAttemptFilter{}); err == nil {
t.Fatalf("порожній кабінет (%q) прийнято без помилки", id)
}
}
}
// Кожен ключ причини зі словника має назву, і навпаки — жодної назви
// без ключа. Ключі беруться з recordLogin (users.go): якщо там
// з'явиться нова причина, вона покажеться сирим рядком, і це
// припустимо — а от назва, прив'язана до ключа з одруківкою, брехала б.
func TestLoginReasonsDictionary(t *testing.T) {
for _, r := range LoginReasons() {
if r.Key == "" || r.Label == "" {
t.Fatalf("порожній рядок словника: %+v", r)
}
if loginReasonByKey[r.Key] != r.Label {
t.Fatalf("ключ %q не знайшовся в мапі", r.Key)
}
}
// Ті чотири, які продукт справді пише сьогодні. Перелік звірений із
// викликами recordLogin у store/users.go.
for _, key := range []string{"bad_password", "no_user", "no_membership", "no_password"} {
if loginReasonByKey[key] == "" {
t.Fatalf("причина %q, яку продукт пише, лишилась без назви", key)
}
}
}
func TestDeviceFromUA(t *testing.T) {
cases := map[string]string{
"": "",
// Edge несе в собі і Chrome, і Safari — саме тому порядок
// перевірок у deviceFromUA має значення.
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36 Edg/120.0": "Edge · Windows",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36": "Chrome · Windows",
"Mozilla/5.0 (X11; Linux x86_64; rv:121.0) Gecko/20100101 Firefox/121.0": "Firefox · Linux",
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 Version/17.0 Mobile/15E148 Safari/604.1": "Safari · iPhone",
// Не браузер. Сплутати скрипт із людиною за столом — найдорожча
// помилка в розборі інциденту.
"curl/8.4.0": "curl (скрипт)",
"Go-http-client/1.1": "Go (скрипт)",
"щось геть невідоме": "",
}
for ua, want := range cases {
if got := deviceFromUA(ua); got != want {
t.Errorf("deviceFromUA(%q) = %q, очікували %q", ua, got, want)
}
}
}
// ---------------------------------------------------------------------
// 2. Проти бази: чужого не видно
// ---------------------------------------------------------------------
// Доказ того, що відсіювання працює, а не оголошене.
//
// Перевірка проти рядка запиту вище не бачить різниці між «умова є» і
// «умова діє»: помилитись можна в самому зіставленні (шукати u.email
// там, де в стовпці лежить логін), і SQL лишиться правильним на вигляд.
// Тому тут — два справжні кабінети, п'ять справжніх рядків і питання до
// бази.
//
// Тест мовчки пропускається без NETPULSE_TEST_DSN. База мусить бути
// ОДНОРАЗОВОЮ — запускати через scripts/dbtest.sh.
func TestLoginAttemptsTenantIsolation(t *testing.T) {
dsn := os.Getenv("NETPULSE_TEST_DSN")
if dsn == "" {
t.Skip("NETPULSE_TEST_DSN не задано — перевірка проти бази пропускається")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Fatalf("підключення: %v", err)
}
t.Cleanup(pool.Close)
st := &Store{pool: pool, bg: pool}
suffix := strings.ReplaceAll(time.Now().Format("150405.000"), ".", "")
a := seedLoginTenant(ctx, t, pool, "logins-a-"+suffix)
b := seedLoginTenant(ctx, t, pool, "logins-b-"+suffix)
now := time.Now().UTC()
// Наливаємо п'ять спроб: дві кабінету A, дві кабінету B і одну
// нічию. Кожна — окремий шлях прив'язки, і всі три шляхи тут є:
// за user_id, за логіном і за поштою.
attempt := func(login string, userID *string, ok bool, reason string, ip string, ago time.Duration) {
t.Helper()
var r any
if reason != "" {
r = reason
}
_, err := pool.Exec(ctx, `
INSERT INTO core.login_attempts (ts, email, ip, user_agent, success, reason, user_id)
VALUES ($1, $2, $3::inet, $4, $5, $6, $7)
`, now.Add(-ago), login, ip, "curl/8.4.0", ok, r, userID)
if err != nil {
t.Fatalf("спроба %q: %v", login, err)
}
}
t.Cleanup(func() {
bg := context.Background()
// login_attempts ні на що не каскадує — прибираємо самі, і саме
// за тими логінами, які завели.
_, _ = pool.Exec(bg, `DELETE FROM core.login_attempts WHERE email = ANY($1)`,
[]string{a.username, a.email, b.username, b.email, "ніхто-" + suffix})
})
attempt(a.username, &a.userID, true, "", "10.10.0.1", 5*time.Minute) // A, за user_id
attempt(a.email, nil, false, "bad_password", "10.10.0.2", 6*time.Minute) // A, за поштою
attempt(b.username, &b.userID, false, "bad_password", "10.20.0.1", 7*time.Minute) // B, за логіном
attempt(b.email, nil, false, "bad_password", "10.20.0.2", 8*time.Minute) // B, за поштою
attempt("ніхто-"+suffix, nil, false, "no_user", "203.0.113.9", 9*time.Minute) // нічия
f := LoginAttemptFilter{From: now.Add(-time.Hour), To: now.Add(time.Minute)}
// Половина перша, без якої друга нічого не доводить: СВОЄ видно.
// Саме на цьому й горять перевірки ізоляції — вони зеленіють від
// того, що не видно нічого.
pageA, err := st.ListLoginAttempts(ctx, a.tenantID, f)
if err != nil {
t.Fatalf("перелік для A: %v", err)
}
if len(pageA.Attempts) != 2 {
t.Fatalf("кабінет A бачить %d спроб замість 2: %+v", len(pageA.Attempts), logins(pageA))
}
// Половина друга: ЧУЖОГО не видно — ні рядком, ні логіном.
for _, at := range pageA.Attempts {
if at.UserID != a.userID {
t.Fatalf("у кабінеті A спроба чужої людини: %+v", at)
}
if strings.EqualFold(at.Login, b.username) || strings.EqualFold(at.Login, b.email) {
t.Fatalf("у кабінеті A видно логін кабінету B: %q", at.Login)
}
if at.Login == "ніхто-"+suffix {
t.Fatalf("у кабінеті A видно неприв'язувану спробу: %q", at.Login)
}
}
// Дзеркально для B — інакше «A бачить дві» могло б означати, що
// зіставлення випадково збіглося саме на цих рядках.
pageB, err := st.ListLoginAttempts(ctx, b.tenantID, f)
if err != nil {
t.Fatalf("перелік для B: %v", err)
}
if len(pageB.Attempts) != 2 {
t.Fatalf("кабінет B бачить %d спроб замість 2: %+v", len(pageB.Attempts), logins(pageB))
}
for _, at := range pageB.Attempts {
if at.UserID != b.userID {
t.Fatalf("у кабінеті B спроба чужої людини: %+v", at)
}
}
// Нічия спроба не показана нікому, але порахована — і в обох
// кабінетах однаково, бо вона нічия для обох.
for _, p := range []struct {
name string
page LoginAttemptsPage
}{{"A", pageA}, {"B", pageB}} {
if p.page.Unattributed == nil {
t.Fatalf("кабінет %s не отримав межі видимості", p.name)
}
if p.page.Unattributed.Attempts < 1 {
t.Fatalf("кабінет %s: неприв'язувані спроби не пораховані (%d)",
p.name, p.page.Unattributed.Attempts)
}
}
// Причина відмови й переказ User-Agent доїжджають до рядка: без
// них сторінка показала б «невдало» без відповіді на «чому» і
// «звідки».
var failed *LoginAttempt
for i := range pageA.Attempts {
if !pageA.Attempts[i].Success {
failed = &pageA.Attempts[i]
}
}
if failed == nil {
t.Fatal("невдала спроба кабінету A не потрапила у вибірку")
}
if failed.ReasonLabel == "" {
t.Fatalf("причина %q лишилась без назви", failed.Reason)
}
if failed.Device != "curl (скрипт)" {
t.Fatalf("переказ User-Agent = %q", failed.Device)
}
if failed.Username != a.username {
t.Fatalf("спроба не прив'язалась до людини: %+v", failed)
}
// Межі й перелік людей рахуються ТІЄЮ Ж умовою — інакше сторінка
// казала б «записи з 3 березня», не показуючи жодного за березень.
actors, err := st.LoginActors(ctx, a.tenantID)
if err != nil {
t.Fatalf("перелік людей A: %v", err)
}
if len(actors) != 1 || actors[0].Label != a.username {
t.Fatalf("у фільтрі «хто» кабінету A: %+v", actors)
}
}
type loginTenant struct {
tenantID string
userID string
username string
email string
}
// seedLoginTenant заводить кабінет із рівно одним учасником.
//
// Свій, а не seedTenant із rls_isolation_test.go: тому потрібні хости,
// лінки й доступи, а тут — людина з логіном і поштою, і нічого більше.
func seedLoginTenant(ctx context.Context, t *testing.T, pool *pgxpool.Pool, slug string) loginTenant {
t.Helper()
r := loginTenant{
username: slug + "-user",
email: slug + "@example.test",
}
must := func(what string, err error) {
t.Helper()
if err != nil {
t.Fatalf("%s (%s): %v", what, slug, err)
}
}
must("кабінет", pool.QueryRow(ctx, `
INSERT INTO core.tenants (slug, name) VALUES ($1, $2) RETURNING id::text
`, slug, "Перевірка входів "+slug).Scan(&r.tenantID))
t.Cleanup(func() {
bg := context.Background()
_, _ = pool.Exec(bg, `DELETE FROM core.tenants WHERE id = $1`, r.tenantID)
_, _ = pool.Exec(bg, `DELETE FROM core.users WHERE username = $1`, r.username)
})
must("користувач", pool.QueryRow(ctx, `
INSERT INTO core.users (username, email, password_hash)
VALUES ($1, $2, 'x') RETURNING id::text
`, r.username, r.email).Scan(&r.userID))
// Системна роль, а не роль кабінету: у 0010 системні ролі заведені
// з tenant_id IS NULL і спільні для всіх кабінетів. Яка саме —
// байдуже: тут перевіряється членство, а не права.
var roleID string
must("роль", pool.QueryRow(ctx, `
SELECT id::text FROM core.roles WHERE tenant_id IS NULL AND key = 'viewer'
`).Scan(&roleID))
_, err := pool.Exec(ctx, `
INSERT INTO core.memberships (tenant_id, user_id, role_id) VALUES ($1, $2, $3)
`, r.tenantID, r.userID, roleID)
must("членство", err)
return r
}
func logins(p LoginAttemptsPage) []string {
out := make([]string, 0, len(p.Attempts))
for _, a := range p.Attempts {
out = append(out, a.Login)
}
return out
}