package com.mailserver.security; import org.junit.jupiter.api.Test; import java.time.Clock; import java.time.Duration; import java.time.Instant; import java.time.ZoneOffset; import java.util.Set; import static org.assertj.core.api.Assertions.assertThat; class LoginGuardTest { /** Clock the test moves by hand. */ static final class TestClock extends Clock { Instant now = Instant.parse("2026-11-06T12:00:00Z"); void plus(Duration d) { now = now.plus(d); } @Override public ZoneOffset getZone() { return ZoneOffset.UTC; } @Override public Clock withZone(java.time.ZoneId zone) { return this; } @Override public Instant instant() { return now; } } private final TestClock clock = new TestClock(); private LoginGuard guard(int perIp, int perAccount, String... trusted) { return new LoginGuard(new LoginGuard.Settings(true, perIp, perAccount, Duration.ofMinutes(15), Duration.ofMinutes(15), Duration.ofHours(24), Duration.ofDays(30), Set.of(trusted), Duration.ZERO), clock); } @Test void ipIsBlockedAfterTheLimitWhateverMailboxItTries() { LoginGuard guard = guard(3, 100); for (int i = 0; i < 2; i++) { guard.failure("1.2.3.4", "user" + i + "@x.test"); } assertThat(guard.blocked("1.2.3.4", "any@x.test")).isFalse(); guard.failure("1.2.3.4", "user9@x.test"); assertThat(guard.retryAfter("1.2.3.4", "any@x.test")).isEqualTo(Duration.ofMinutes(15)); assertThat(guard.blocked("5.6.7.8", "any@x.test")).isFalse(); clock.plus(Duration.ofMinutes(15)); assertThat(guard.blocked("1.2.3.4", "any@x.test")).isFalse(); } @Test void failuresOutsideTheWindowDoNotCount() { LoginGuard guard = guard(3, 100); guard.failure("1.2.3.4", "a@x.test"); guard.failure("1.2.3.4", "a@x.test"); clock.plus(Duration.ofMinutes(16)); guard.failure("1.2.3.4", "a@x.test"); assertThat(guard.blocked("1.2.3.4", "a@x.test")).isFalse(); } @Test void eachNextBlockOfTheSameIpIsTwiceAsLongUpToTheMaximum() { LoginGuard guard = guard(2, 100); Duration expected = Duration.ofMinutes(15); for (int round = 0; round < 10; round++) { guard.failure("1.2.3.4", "a@x.test"); guard.failure("1.2.3.4", "a@x.test"); Duration wait = guard.retryAfter("1.2.3.4", "b@x.test"); assertThat(wait).isEqualTo(expected); clock.plus(wait); expected = expected.multipliedBy(2).compareTo(Duration.ofHours(24)) > 0 ? Duration.ofHours(24) : expected.multipliedBy(2); } // a quiet day forgives clock.plus(Duration.ofHours(25)); guard.failure("1.2.3.4", "a@x.test"); guard.failure("1.2.3.4", "a@x.test"); assertThat(guard.retryAfter("1.2.3.4", "b@x.test")).isEqualTo(Duration.ofMinutes(15)); } @Test void attemptsWhileBlockedDoNotProlongTheBlock() { LoginGuard guard = guard(2, 100); guard.failure("1.2.3.4", "a@x.test"); guard.failure("1.2.3.4", "a@x.test"); clock.plus(Duration.ofMinutes(10)); guard.failure("1.2.3.4", "a@x.test"); assertThat(guard.retryAfter("1.2.3.4", "a@x.test")).isEqualTo(Duration.ofMinutes(5)); } @Test void distributedAttackBlocksTheMailboxButNotItsOwnerFromAKnownIp() { LoginGuard guard = guard(100, 5); guard.success("10.0.0.7", "Alice@X.test"); for (int i = 0; i < 5; i++) { guard.failure("203.0.113." + i, "alice@x.test"); } assertThat(guard.blocked("198.51.100.1", "alice@x.test")).isTrue(); assertThat(guard.blocked("198.51.100.1", "bob@x.test")).isFalse(); assertThat(guard.blocked("10.0.0.7", "alice@x.test")).isFalse(); } @Test void successForgivesTheIpFailuresButNotAnActiveBlock() { LoginGuard guard = guard(3, 100); guard.failure("1.2.3.4", "a@x.test"); guard.failure("1.2.3.4", "a@x.test"); guard.success("1.2.3.4", "a@x.test"); guard.failure("1.2.3.4", "a@x.test"); guard.failure("1.2.3.4", "a@x.test"); assertThat(guard.blocked("1.2.3.4", "a@x.test")).isFalse(); guard.failure("1.2.3.4", "a@x.test"); assertThat(guard.blocked("1.2.3.4", "a@x.test")).isTrue(); guard.success("1.2.3.4", "a@x.test"); assertThat(guard.blocked("1.2.3.4", "other@x.test")).isTrue(); } @Test void trustedIpIsNeverBlockedByIp() { LoginGuard guard = guard(2, 100, "172.18.0.1"); for (int i = 0; i < 10; i++) { guard.failure("172.18.0.1", "u" + i + "@x.test"); } assertThat(guard.blocked("172.18.0.1", "new@x.test")).isFalse(); } @Test void sweepForgetsOldKeys() { LoginGuard guard = guard(5, 5); guard.failure("1.2.3.4", "a@x.test"); assertThat(guard.trackedIps()).isEqualTo(1); assertThat(guard.trackedAccounts()).isEqualTo(1); clock.plus(Duration.ofHours(25).plusMinutes(16)); guard.sweep(); assertThat(guard.trackedIps()).isZero(); assertThat(guard.trackedAccounts()).isZero(); } @Test void disabledGuardNeverBlocks() { LoginGuard guard = LoginGuard.disabled(); for (int i = 0; i < 100; i++) { guard.failure("1.2.3.4", "a@x.test"); } assertThat(guard.blocked("1.2.3.4", "a@x.test")).isFalse(); assertThat(guard.failureDelay()).isZero(); } @Test void humanDuration() { assertThat(LoginGuard.human(Duration.ofSeconds(30))).isEqualTo("1 min"); assertThat(LoginGuard.human(Duration.ofMinutes(15))).isEqualTo("15 min"); assertThat(LoginGuard.human(Duration.ofHours(4))).isEqualTo("4 h"); } }