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:
be-nj
2026-08-26 14:12:51 +02:00
parent f1a6600e42
commit b52ef79c01
9 changed files with 632 additions and 55 deletions

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

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