Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
33a6acb0bf | ||
|
|
9d6572bc33 |
@@ -0,0 +1,71 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package app.echo_lot.engine
|
||||||
|
|
||||||
|
import app.echo_lot.protocol.Compat
|
||||||
|
import app.echo_lot.protocol.ControlClient
|
||||||
|
import app.echo_lot.protocol.VersionRefused
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertNotNull
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
import kotlin.test.fail
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks the version gate against a LIVE server — the half that unit tests cannot reach, because
|
||||||
|
* the whole point is that two independently-built artifacts agree. Self-skips without
|
||||||
|
* ECHOLOT_LIVE_*.
|
||||||
|
*/
|
||||||
|
class LiveCompatTest {
|
||||||
|
|
||||||
|
private val url = System.getenv("ECHOLOT_LIVE_URL")
|
||||||
|
private val pin = System.getenv("ECHOLOT_LIVE_PIN")
|
||||||
|
private val cred = System.getenv("ECHOLOT_LIVE_CRED")
|
||||||
|
|
||||||
|
private fun clientAs(version: String) = ControlClient(url!!, setOf(pin!!), version)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun theServerAdvertisesAndEnforcesItsWindow() {
|
||||||
|
if (url == null || pin == null || cred == null) {
|
||||||
|
println("LiveCompatTest skipped (no ECHOLOT_LIVE_* env)"); return
|
||||||
|
}
|
||||||
|
|
||||||
|
// The profile must state the window — without it the app cannot pre-empt a refusal.
|
||||||
|
val profile = clientAs("0.2.0").profile(cred)
|
||||||
|
println("server ${profile.serverVersion} protocol=${profile.compat.protocolVersion} " +
|
||||||
|
"accepts app [${profile.compat.appMin}, ${profile.compat.appMax})")
|
||||||
|
assertTrue(profile.compat.protocolVersion.isNotBlank(), "profile omits protocol_version")
|
||||||
|
assertTrue(profile.compat.appMin.isNotBlank(), "profile omits app_min")
|
||||||
|
|
||||||
|
// This build must be inside it, or every other live test here is meaningless.
|
||||||
|
val verdict = Compat.check(profile, "0.2.0")
|
||||||
|
assertEquals(Compat.Verdict.OK, verdict.verdict, verdict.message ?: "")
|
||||||
|
|
||||||
|
// The profile stays reachable for a version the server would otherwise refuse: that is
|
||||||
|
// how a refused client discovers what it needs.
|
||||||
|
val ancient = clientAs("0.1.0")
|
||||||
|
val stillReadable = ancient.profile(cred)
|
||||||
|
assertEquals(profile.serverVersion, stillReadable.serverVersion,
|
||||||
|
"the profile endpoint must never be gated on app version")
|
||||||
|
|
||||||
|
// And a gated endpoint refuses it, with a message naming the window.
|
||||||
|
try {
|
||||||
|
ancient.createSession(cred, System.getenv("ECHOLOT_LIVE_TARGET") ?: "fmr")
|
||||||
|
fail("server accepted a session from an out-of-window app")
|
||||||
|
} catch (e: VersionRefused) {
|
||||||
|
val msg = assertNotNull(e.message)
|
||||||
|
println("refused as expected: $msg")
|
||||||
|
assertTrue(msg.contains("0.1.0"), "refusal should name the offending version: $msg")
|
||||||
|
assertTrue(msg.contains(profile.compat.appMin), "refusal should name the window: $msg")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Too new is refused the same way — the window is a range, not a floor.
|
||||||
|
try {
|
||||||
|
clientAs("99.0.0").createSession(cred, System.getenv("ECHOLOT_LIVE_TARGET") ?: "fmr")
|
||||||
|
fail("server accepted a session from an app above its window")
|
||||||
|
} catch (e: VersionRefused) {
|
||||||
|
println("too-new refused as expected: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,8 @@
|
|||||||
package app.echo_lot.protocol
|
package app.echo_lot.protocol
|
||||||
|
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.jsonObject
|
||||||
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
import java.net.URL
|
import java.net.URL
|
||||||
import javax.net.ssl.HttpsURLConnection
|
import javax.net.ssl.HttpsURLConnection
|
||||||
|
|
||||||
@@ -59,13 +61,16 @@ class ControlClient(
|
|||||||
if (conn.responseCode == 426) throw VersionRefused(extractError(body) ?: body.take(200))
|
if (conn.responseCode == 426) throw VersionRefused(extractError(body) ?: body.take(200))
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Pulls the "error" string out of a JSON body without pulling in a parser for one field. */
|
/**
|
||||||
private fun extractError(body: String): String? =
|
* Pulls the "error" string out of a JSON body.
|
||||||
Regex(""""error"\s*:\s*"((?:[^"\\]|\\.)*)"""").find(body)
|
*
|
||||||
?.groupValues?.get(1)
|
* Parsed rather than pattern-matched: an encoder may legitimately escape characters in the
|
||||||
?.replace("\\\"", "\"")
|
* message (Go escapes ">" by default), and a regex hands the user "needs \u003e= 0.2.0".
|
||||||
?.replace("\\n", "\n")
|
* The parser knows how to undo every escape; a regex would have to be taught each one.
|
||||||
?.replace("\\\\", "\\")
|
*/
|
||||||
|
private fun extractError(body: String): String? = runCatching {
|
||||||
|
json.parseToJsonElement(body).jsonObject["error"]?.jsonPrimitive?.content
|
||||||
|
}.getOrNull()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reads the response body, and turns a 426 into [VersionRefused] first.
|
* Reads the response body, and turns a 426 into [VersionRefused] first.
|
||||||
|
|||||||
@@ -183,7 +183,7 @@ func Check(peer string, r Range, peerName string) (Verdict, string) {
|
|||||||
return TooOld, fmt.Sprintf("%s %s is older than this build supports (needs %s). Update the %s.",
|
return TooOld, fmt.Sprintf("%s %s is older than this build supports (needs %s). Update the %s.",
|
||||||
peerName, v, r, peerName)
|
peerName, v, r, peerName)
|
||||||
case r.HasMax && !v.Less(r.Max):
|
case r.HasMax && !v.Less(r.Max):
|
||||||
return TooNew, fmt.Sprintf("%s %s is newer than this build supports (accepts %s). Update this side, or point at a %s within range.",
|
return TooNew, fmt.Sprintf("%s %s is newer than this build supports (accepts %s). Update this side, or use a version of the %s within that range.",
|
||||||
peerName, v, r, peerName)
|
peerName, v, r, peerName)
|
||||||
}
|
}
|
||||||
return OK, ""
|
return OK, ""
|
||||||
|
|||||||
@@ -430,7 +430,12 @@ func bearer(r *http.Request) string {
|
|||||||
func writeJSON(w http.ResponseWriter, code int, v any) {
|
func writeJSON(w http.ResponseWriter, code int, v any) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.WriteHeader(code)
|
w.WriteHeader(code)
|
||||||
_ = json.NewEncoder(w).Encode(v)
|
enc := json.NewEncoder(w)
|
||||||
|
// Go escapes <, > and & by default, for JSON embedded in HTML. This is an API, and the
|
||||||
|
// escaping is actively harmful here: a refusal message reading "needs >= 0.2.0" is what
|
||||||
|
// the user ends up seeing. Nothing we emit is ever interpolated into a page.
|
||||||
|
enc.SetEscapeHTML(false)
|
||||||
|
_ = enc.Encode(v)
|
||||||
}
|
}
|
||||||
|
|
||||||
// enroll redeems a single-use enrollment token for a device credential (§2.1).
|
// enroll redeems a single-use enrollment token for a device credential (§2.1).
|
||||||
|
|||||||
Reference in New Issue
Block a user