package service import ( "testing" "hy2xs-admin/model/entity" ) // Границы политики доступа проверяются ТОЧЕЧНО, а не «примерно». // // Именно на границах два прежних экземпляра правила и расходились: авторизация // не пускала пира при `usage == quota`, а принудительное отключение требовало // строгого превышения и сессию не рвало. Пир с исчерпанной квотой не мог // подключиться заново, но и не отключался никогда. // // Поэтому набор состоит в основном из равенств: `quota == usage`, // `now == expiresAt`, `now == bannedUntil`, `quota == 0`. const policyNow = int64(1_700_000_000_000) // policyPeer собирает пира с полным набором решающих полей. func policyPeer(disabled, quota, download, upload, expiresAt, bannedUntil int64) entity.Peer { return entity.Peer{ Disabled: &disabled, QuotaBytes: "a, DownloadBytes: &download, UploadBytes: &upload, ExpiresAt: &expiresAt, BannedUntil: &bannedUntil, } } func TestPeerAccessDeniedBoundaries(t *testing.T) { cases := []struct { name string peer entity.Peer denied bool }{ { name: "обычный пир без ограничений", peer: policyPeer(0, -1, 1_000, 2_000, 0, 0), denied: false, }, { name: "отключён оператором", peer: policyPeer(1, -1, 0, 0, 0, 0), denied: true, }, // --- квота --- { // Единственный способ сказать «без ограничения». name: "quota = -1 — безлимит при любом расходе", peer: policyPeer(0, -1, 1<<40, 1<<40, 0, 0), denied: false, }, { // Ноль байтов — это ноль байтов, а не отсутствие ограничения. // Прежний cron на такой строке сессию не рвал вовсе. name: "quota = 0 — доступа нет", peer: policyPeer(0, 0, 0, 0, 0, 0), denied: true, }, { // Главная граница QUOTA-01: счётчики растут порциями по ответу // Traffic Stats API, поэтому точное равенство — обычный исход // последнего сбора, а не экзотика. name: "usage = quota — лимит исчерпан", peer: policyPeer(0, 1_000, 600, 400, 0, 0), denied: true, }, { name: "usage на байт меньше квоты", peer: policyPeer(0, 1_000, 600, 399, 0, 0), denied: false, }, { name: "usage больше квоты", peer: policyPeer(0, 1_000, 600, 401, 0, 0), denied: true, }, { // Квота считается по сумме обоих направлений. name: "квота исчерпана одним download", peer: policyPeer(0, 1_000, 1_000, 0, 0, 0), denied: true, }, // --- срок действия --- { name: "expiresAt = 0 — срок не задан", peer: policyPeer(0, -1, 0, 0, 0, 0), denied: false, }, { // Момент наступил — значит уже истёк. name: "expiresAt = now — срок истёк", peer: policyPeer(0, -1, 0, 0, policyNow, 0), denied: true, }, { name: "expiresAt на миллисекунду впереди", peer: policyPeer(0, -1, 0, 0, policyNow+1, 0), denied: false, }, { name: "expiresAt в прошлом", peer: policyPeer(0, -1, 0, 0, policyNow-1, 0), denied: true, }, // --- временная блокировка --- { name: "bannedUntil = 0 — блокировки нет", peer: policyPeer(0, -1, 0, 0, 0, 0), denied: false, }, { // Блокировка «до» этого момента уже закончилась. name: "bannedUntil = now — блокировка истекла", peer: policyPeer(0, -1, 0, 0, 0, policyNow), denied: false, }, { name: "bannedUntil в будущем", peer: policyPeer(0, -1, 0, 0, 0, policyNow+1), denied: true, }, { name: "bannedUntil в прошлом", peer: policyPeer(0, -1, 0, 0, 0, policyNow-1), denied: false, }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { if got := peerAccessDenied(tc.peer, policyNow); got != tc.denied { t.Fatalf("peerAccessDenied = %v, ожидалось %v", got, tc.denied) } }) } } // Строка без решающего поля — повреждённые данные, а не пир без ограничений. // Все эти колонки объявлены NOT NULL с DEFAULT, поэтому nil здесь означать // может только повреждение, и на пути принятия решения о доступе оно обязано // вести к отказу. func TestPeerAccessDeniedIsFailClosedOnCorruptedRow(t *testing.T) { fields := map[string]func(*entity.Peer){ "disabled": func(p *entity.Peer) { p.Disabled = nil }, "quota_bytes": func(p *entity.Peer) { p.QuotaBytes = nil }, "download_bytes": func(p *entity.Peer) { p.DownloadBytes = nil }, "upload_bytes": func(p *entity.Peer) { p.UploadBytes = nil }, "expires_at": func(p *entity.Peer) { p.ExpiresAt = nil }, "banned_until": func(p *entity.Peer) { p.BannedUntil = nil }, } for field, corrupt := range fields { t.Run(field, func(t *testing.T) { // Исходный пир заведомо имеет доступ: отказ обязан прийти именно // от повреждения, а не от прочих ограничений. peer := policyPeer(0, -1, 0, 0, 0, 0) corrupt(&peer) if !peerAccessDenied(peer, policyNow) { t.Fatalf("строка без %s принята как пир без ограничений", field) } }) } } // Авторизация и принудительное отключение обязаны отвечать ОДИНАКОВО на любом // состоянии: расхождение между ними — это и есть класс дефектов QUOTA-01. // // Проверяется свойство, а не реализация: обе стороны спрашивают одну функцию, // поэтому тест ловит попытку завести второй предикат, а не текущий код. func TestPeerAccessDecisionIsTotal(t *testing.T) { values := []int64{-1, 0, 1_000} times := []int64{policyNow - 1, policyNow, policyNow + 1} for _, quota := range values { for _, used := range []int64{0, 500, 1_000, 1_500} { for _, expires := range append([]int64{0}, times...) { for _, banned := range append([]int64{0}, times...) { peer := policyPeer(0, quota, used, 0, expires, banned) first := peerAccessDenied(peer, policyNow) second := peerAccessDenied(peer, policyNow) if first != second { t.Fatalf( "решение недетерминировано: quota=%d used=%d expires=%d banned=%d", quota, used, expires, banned, ) } } } } } }