Test the three bugs that reached the living room, and script the release
The rate limit, the version compare and the download check were all wrapped in Android — a Context, a socket, a file — so none of them had a test, and all three shipped broken: the remote thrown out after five reconnects (0.11.1), a truncated APK handed to the installer (0.11.5), and a version compare that would read 0.11.10 as older than 0.11.9 the moment we get there. They are now plain Kotlin in AttemptBudget and UpdateRules, with the clock injected, and 20 tests covering the failures themselves. AttemptBudget also prunes expired entries before evicting: dropping only empty queues let the map grow without bound while every tracked address held one fresh failure. tests/smoke.sh installs the debug APK on the headless emulator, walks the first screen with the D-pad and fails on a crash or ANR — the class of bug unit tests cannot see, and the reason 0.11.0 crashed on startup. Forty seconds end to end. tools/release.sh does the bump, tests, signed build, tag, apk branch and Gitea release in one command. It refuses an unsigned build and never writes the release notes itself; commit subjects are offered as a draft. tests/helpers/jdk.sh picks a JDK Gradle can run on: Ubuntu moved default-java to 25, which fails the build with a bare "IllegalArgumentException: 25.0.4". Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
101
tests/unit/AttemptBudgetTest.kt
Normal file
101
tests/unit/AttemptBudgetTest.kt
Normal file
@@ -0,0 +1,101 @@
|
||||
package dev.castarr.tv.server
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class AttemptBudgetTest {
|
||||
|
||||
private var clock = 0L
|
||||
private fun budget(
|
||||
windowMs: Long = 60_000L,
|
||||
maxFailures: Int = 5,
|
||||
maxTracked: Int = 64,
|
||||
) = AttemptBudget(windowMs, maxFailures, maxTracked) { clock }
|
||||
|
||||
/**
|
||||
* The 0.11.0 bug: the remote reconnects on every network hiccup, and
|
||||
* counting those successful handshakes threw the phone out after five.
|
||||
*/
|
||||
@Test
|
||||
fun `successful connections never cost budget`() {
|
||||
val budget = budget()
|
||||
repeat(50) {
|
||||
assertTrue(budget.allows("10.0.0.5"))
|
||||
budget.clear("10.0.0.5")
|
||||
}
|
||||
assertTrue(budget.allows("10.0.0.5"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `five failures in the window lock the address out`() {
|
||||
val budget = budget()
|
||||
repeat(5) {
|
||||
assertTrue(budget.allows("10.0.0.5"))
|
||||
budget.recordFailure("10.0.0.5")
|
||||
}
|
||||
assertFalse(budget.allows("10.0.0.5"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a success after failures wipes the slate`() {
|
||||
val budget = budget()
|
||||
repeat(4) { budget.recordFailure("10.0.0.5") }
|
||||
budget.clear("10.0.0.5")
|
||||
repeat(4) {
|
||||
assertTrue(budget.allows("10.0.0.5"))
|
||||
budget.recordFailure("10.0.0.5")
|
||||
}
|
||||
assertTrue(budget.allows("10.0.0.5"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `failures expire once the window has passed`() {
|
||||
val budget = budget()
|
||||
repeat(5) { budget.recordFailure("10.0.0.5") }
|
||||
assertFalse(budget.allows("10.0.0.5"))
|
||||
clock += 60_001
|
||||
assertTrue(budget.allows("10.0.0.5"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the window slides, it does not reset in blocks`() {
|
||||
val budget = budget()
|
||||
repeat(4) {
|
||||
budget.recordFailure("10.0.0.5")
|
||||
clock += 20_000
|
||||
}
|
||||
// Two of the four are older than a minute by now, so there is room.
|
||||
assertTrue(budget.allows("10.0.0.5"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `one address cannot spend another's budget`() {
|
||||
val budget = budget()
|
||||
repeat(5) { budget.recordFailure("10.0.0.9") }
|
||||
assertFalse(budget.allows("10.0.0.9"))
|
||||
assertTrue(budget.allows("10.0.0.5"))
|
||||
}
|
||||
|
||||
/** An attacker cycling source addresses must not grow the map forever. */
|
||||
@Test
|
||||
fun `tracked addresses stay bounded under a flood of fresh ones`() {
|
||||
val budget = budget(maxTracked = 8)
|
||||
repeat(500) { i ->
|
||||
budget.recordFailure("10.0.0.$i")
|
||||
clock += 10
|
||||
}
|
||||
assertTrue(budget.trackedAddresses() <= 8)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `eviction drops the stale addresses, not the active one`() {
|
||||
val budget = budget(maxTracked = 4)
|
||||
repeat(4) { i -> budget.recordFailure("10.0.1.$i") }
|
||||
clock += 60_001
|
||||
repeat(5) { budget.recordFailure("10.0.0.5") }
|
||||
assertFalse(budget.allows("10.0.0.5"))
|
||||
assertEquals(1, budget.trackedAddresses())
|
||||
}
|
||||
}
|
||||
76
tests/unit/UpdateRulesTest.kt
Normal file
76
tests/unit/UpdateRulesTest.kt
Normal file
@@ -0,0 +1,76 @@
|
||||
package dev.castarr.tv.update
|
||||
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class UpdateRulesTest {
|
||||
|
||||
@Test
|
||||
fun `a higher patch level is newer`() {
|
||||
assertTrue(UpdateRules.isNewer("0.11.5", "0.11.4"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the same version is not newer`() {
|
||||
assertFalse(UpdateRules.isNewer("0.11.5", "0.11.5"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an older release never offers itself as an update`() {
|
||||
assertFalse(UpdateRules.isNewer("0.11.4", "0.11.5"))
|
||||
}
|
||||
|
||||
/** Segments are numbers, not text — "10" beats "9". */
|
||||
@Test
|
||||
fun `double digit segments compare numerically`() {
|
||||
assertTrue(UpdateRules.isNewer("0.11.10", "0.11.9"))
|
||||
assertFalse(UpdateRules.isNewer("0.11.9", "0.11.10"))
|
||||
assertTrue(UpdateRules.isNewer("0.12.0", "0.9.99"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a missing segment counts as zero`() {
|
||||
assertFalse(UpdateRules.isNewer("0.12", "0.12.0"))
|
||||
assertTrue(UpdateRules.isNewer("0.12.1", "0.12"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a leading v on the tag makes no difference`() {
|
||||
assertTrue(UpdateRules.isNewer("v0.11.5", "0.11.4"))
|
||||
assertFalse(UpdateRules.isNewer("v0.11.4", "v0.11.4"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `garbage in a tag does not offer an update`() {
|
||||
assertFalse(UpdateRules.isNewer("", "0.11.5"))
|
||||
assertFalse(UpdateRules.isNewer("nightly", "0.11.5"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a complete download passes`() {
|
||||
assertTrue(UpdateRules.isComplete(actualBytes = 6_515_429, announcedBytes = 6_515_429))
|
||||
}
|
||||
|
||||
/** The 0.11.5 bug: a truncated APK left the installer hanging. */
|
||||
@Test
|
||||
fun `a truncated download is rejected`() {
|
||||
assertFalse(UpdateRules.isComplete(actualBytes = 3_000_000, announcedBytes = 6_515_429))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an empty download is rejected even when nothing was announced`() {
|
||||
assertFalse(UpdateRules.isComplete(actualBytes = 0, announcedBytes = -1))
|
||||
assertFalse(UpdateRules.isComplete(actualBytes = 0, announcedBytes = 0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unannounced length cannot be checked, so any content passes`() {
|
||||
assertTrue(UpdateRules.isComplete(actualBytes = 6_515_429, announcedBytes = -1))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `more bytes than announced is rejected too`() {
|
||||
assertFalse(UpdateRules.isComplete(actualBytes = 7_000_000, announcedBytes = 6_515_429))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user