test(audio-rooms): nostrnests interop harness + /auth ping (phase 1/3)
Brings up the nostrnests reference server (https://github.com/nostrnests/nests) locally via Docker Compose so we can drive `:nestsClient`'s production code against the real MoQ relay + NIP-98 auth sidecar. Mirrors the `:quic` `InteropRunner` pattern (aioquic Docker, opt-in via `-DinteropHost=…`): - Set `-DnestsInterop=true` to enable; default `:nestsClient:jvmTest` runs skip via JUnit `Assume.assumeTrue` (shown as <skipped>, not <failure>). - Repo cloned + cached at `~/.cache/amethyst-nests-interop/nests/`, pinned to the `DEFAULT_REVISION` (currently `main`; override via `-DnestsInteropRev=<sha>` to lock in for reproducibility). - `docker compose -f docker-compose-moq.yml up -d` brings up moq-relay (host 4443 TCP+UDP), moq-auth (host 8090), strfry (7777). Port-probes 4443 + 8090 with a 90 s timeout. - `close()` runs `docker compose down -v --remove-orphans`. Tests use `@BeforeClass`/`@AfterClass` to amortise the ~30-60 s spin-up across all cases in one class. Phase-1 ping test (NostrNestsAuthInteropTest): - Generates an ephemeral KeyPair / NostrSignerInternal via Quartz. - POSTs `<authBase>/auth` with `{"namespace":"nests/30312:<pubkey>:<roomId>", "publish":true}`, NIP-98 Authorization header signed for that exact (url, method, payload) tuple. - Asserts 200 + a `"token":"…"` JWT in the response body. Doesn't yet route through `OkHttpNestsClient` because the production client's wire shape (GET `<base>/<roomId>` returning `{endpoint, token, codec, sample_rate}`) does not match nostrnests' actual API (POST `<base>/auth` with `{namespace, publish}` body, returning just `{token}` — endpoint comes from the NIP-53 event's `endpoint` tag instead of the HTTP response). Phase 2 of this audit refactors production to match; this test documents the divergence on the wire so the refactor has a clear target. Verified: harness compiles clean; default `:nestsClient:jvmTest` shows the test as <skipped> (not <failure>) when `nestsInterop` property is unset. Files: - nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/interop/NostrNestsHarness.kt - nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/interop/NostrNestsAuthInteropTest.kt
This commit is contained in:
+120
@@ -0,0 +1,120 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.nestsclient.interop
|
||||
|
||||
import com.vitorpamplona.nestsclient.NestsAuth
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import org.junit.AfterClass
|
||||
import org.junit.BeforeClass
|
||||
import org.junit.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Phase-1 interop smoke test. Brings up a real nostrnests stack (auth
|
||||
* sidecar + MoQ relay + strfry) via Docker Compose, then exercises the
|
||||
* `/auth` endpoint with a hand-rolled NIP-98 request that matches what
|
||||
* the server actually expects.
|
||||
*
|
||||
* Doesn't yet use [com.vitorpamplona.nestsclient.NestsClient] —
|
||||
* `OkHttpNestsClient`'s wire shape (GET `<base>/<roomId>` with no body,
|
||||
* expecting `{endpoint, token}` back) doesn't match the real server
|
||||
* (POST `<base>/auth` with `{namespace, publish}` body, returning just
|
||||
* `{token}`). Phase 2 of this audit refactors the production client to
|
||||
* match; until then this test documents the divergence on the wire.
|
||||
*
|
||||
* Skipped by default — set `-DnestsInterop=true` to enable.
|
||||
*/
|
||||
class NostrNestsAuthInteropTest {
|
||||
@Test
|
||||
fun auth_endpoint_returns_jwt_for_a_well_formed_nip98_request() =
|
||||
runBlocking {
|
||||
NostrNestsHarness.assumeNestsInterop()
|
||||
val harness = harnessOrNull ?: return@runBlocking
|
||||
|
||||
val keys = KeyPair()
|
||||
val signer = NostrSignerInternal(keys)
|
||||
val pubkeyHex = signer.pubKey
|
||||
|
||||
val authUrl = "${harness.authBaseUrl}/auth"
|
||||
val roomId = "interop-${System.currentTimeMillis()}"
|
||||
val namespace = "nests/30312:$pubkeyHex:$roomId"
|
||||
val body = """{"namespace":"$namespace","publish":true}"""
|
||||
|
||||
val authHeader =
|
||||
NestsAuth.header(
|
||||
signer = signer,
|
||||
url = authUrl,
|
||||
method = "POST",
|
||||
payload = body.toByteArray(),
|
||||
)
|
||||
|
||||
val request =
|
||||
Request
|
||||
.Builder()
|
||||
.url(authUrl)
|
||||
.post(body.toRequestBody("application/json".toMediaType()))
|
||||
.header("Authorization", authHeader)
|
||||
.build()
|
||||
|
||||
val (status, responseBody) =
|
||||
http.newCall(request).execute().use { response ->
|
||||
response.code to (response.body.string())
|
||||
}
|
||||
|
||||
assertEquals(
|
||||
200,
|
||||
status,
|
||||
"POST /auth should return 200 with a valid NIP-98 + namespace; got $status: $responseBody",
|
||||
)
|
||||
// moq-auth's response is `{"token":"<jwt>"}` per its index.ts.
|
||||
assertTrue(
|
||||
responseBody.contains("\"token\":\""),
|
||||
"Expected JWT in `token` field, got: $responseBody",
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val http = OkHttpClient()
|
||||
private var harnessOrNull: NostrNestsHarness? = null
|
||||
|
||||
@BeforeClass
|
||||
@JvmStatic
|
||||
fun setUpHarness() {
|
||||
if (NostrNestsHarness.isEnabled()) {
|
||||
harnessOrNull = NostrNestsHarness.start()
|
||||
}
|
||||
}
|
||||
|
||||
@AfterClass
|
||||
@JvmStatic
|
||||
fun tearDownHarness() {
|
||||
harnessOrNull?.close()
|
||||
harnessOrNull = null
|
||||
}
|
||||
}
|
||||
}
|
||||
+206
@@ -0,0 +1,206 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.nestsclient.interop
|
||||
|
||||
import java.io.File
|
||||
import java.net.Socket
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
|
||||
/**
|
||||
* Test fixture that boots a local nostrnests stack via Docker Compose so
|
||||
* `:nestsClient`'s production code can be exercised end-to-end against
|
||||
* the reference server (https://github.com/nostrnests/nests).
|
||||
*
|
||||
* Mirrors the pattern used by `:quic`'s `InteropRunner` against aioquic:
|
||||
*
|
||||
* - opt-in via `-DnestsInterop=true` system property; default test runs
|
||||
* skip via `assumeNestsInterop()` so CI without Docker stays green
|
||||
* - clones the nostrnests repo at a pinned commit on first use, caches
|
||||
* under `~/.cache/amethyst-nests-interop/`
|
||||
* - brings up `docker compose -f docker-compose-moq.yml up -d` (auth
|
||||
* sidecar on host port 8090, MoQ relay on 4443 TCP+UDP)
|
||||
* - port-probes both services until they accept connections
|
||||
* - tears the stack down via `docker compose down -v` on [close]
|
||||
*
|
||||
* One harness instance per test class — startup is ~30-60 s, so amortise
|
||||
* across as many cases as possible.
|
||||
*/
|
||||
class NostrNestsHarness private constructor(
|
||||
private val workDir: File,
|
||||
val authBaseUrl: String,
|
||||
val moqEndpoint: String,
|
||||
) : AutoCloseable {
|
||||
private var stopped = false
|
||||
|
||||
override fun close() {
|
||||
if (stopped) return
|
||||
stopped = true
|
||||
runDocker(workDir, "down", "-v", "--remove-orphans")
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** System property gate. Tests should call [assumeNestsInterop] first. */
|
||||
const val ENABLE_PROPERTY = "nestsInterop"
|
||||
|
||||
/** Tests that depend on the harness call this in `@BeforeTest`. */
|
||||
fun isEnabled(): Boolean = System.getProperty(ENABLE_PROPERTY) == "true"
|
||||
|
||||
/**
|
||||
* Pin the nostrnests revision to a known-good SHA. Bump deliberately
|
||||
* — drift on `main` should not silently change interop expectations.
|
||||
* Override at runtime via `-DnestsInteropRev=<sha-or-branch>`.
|
||||
*/
|
||||
const val DEFAULT_REVISION = "main"
|
||||
|
||||
const val AUTH_HOST_PORT = 8090
|
||||
const val MOQ_HOST_PORT = 4443
|
||||
|
||||
private const val REPO_URL = "https://github.com/nostrnests/nests.git"
|
||||
private const val COMPOSE_FILE = "docker-compose-moq.yml"
|
||||
private const val PORT_READY_TIMEOUT_MS = 90_000L
|
||||
private const val PORT_PROBE_INTERVAL_MS = 500L
|
||||
|
||||
/**
|
||||
* Bring the stack up. Caller must `close()` to tear it down — use
|
||||
* `try-with-resources` / Kotlin's `use { … }`.
|
||||
*/
|
||||
fun start(): NostrNestsHarness {
|
||||
check(isEnabled()) {
|
||||
"NostrNestsHarness.start called without -D$ENABLE_PROPERTY=true. " +
|
||||
"Call NostrNestsHarness.assumeNestsInterop() first to skip cleanly."
|
||||
}
|
||||
|
||||
val workDir = ensureRepo()
|
||||
// Bring everything up detached. The compose file's services log
|
||||
// to stdout; we don't tail them — failures surface via
|
||||
// port-probe timeouts.
|
||||
runDocker(workDir, "up", "-d")
|
||||
try {
|
||||
waitForPort("127.0.0.1", AUTH_HOST_PORT, PORT_READY_TIMEOUT_MS)
|
||||
waitForPort("127.0.0.1", MOQ_HOST_PORT, PORT_READY_TIMEOUT_MS)
|
||||
} catch (t: Throwable) {
|
||||
// If readiness probe fails, tear down so we don't leak the
|
||||
// stack into the next test run.
|
||||
runCatching { runDocker(workDir, "down", "-v", "--remove-orphans") }
|
||||
throw t
|
||||
}
|
||||
return NostrNestsHarness(
|
||||
workDir = workDir,
|
||||
authBaseUrl = "http://127.0.0.1:$AUTH_HOST_PORT",
|
||||
// The moq-relay terminates TLS — we use a permissive
|
||||
// certificate validator on the QuicWebTransportFactory in
|
||||
// tests. Path is `/anon` per the relay's default WT route.
|
||||
moqEndpoint = "https://127.0.0.1:$MOQ_HOST_PORT/anon",
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience for `@BeforeTest`: throws JUnit's `AssumptionViolated`
|
||||
* (via `org.junit.Assume`) when the property isn't set, which JUnit
|
||||
* reports as "skipped" rather than "failed". Falls back to a regular
|
||||
* exception if JUnit's Assume isn't on the classpath.
|
||||
*/
|
||||
fun assumeNestsInterop() {
|
||||
if (isEnabled()) return
|
||||
val msg = "Skipping nostrnests interop test — set -D$ENABLE_PROPERTY=true to enable"
|
||||
try {
|
||||
val assume = Class.forName("org.junit.Assume")
|
||||
val assumeTrue =
|
||||
assume.getMethod("assumeTrue", String::class.java, Boolean::class.javaPrimitiveType)
|
||||
assumeTrue.invoke(null, msg, false)
|
||||
} catch (e: java.lang.reflect.InvocationTargetException) {
|
||||
// Unwrap so JUnit sees the real AssumptionViolatedException
|
||||
// (treated as "skipped") instead of the reflection wrapper
|
||||
// (treated as "failed").
|
||||
throw e.targetException ?: e
|
||||
} catch (_: ClassNotFoundException) {
|
||||
throw IllegalStateException(msg)
|
||||
}
|
||||
}
|
||||
|
||||
// ----- internals -----
|
||||
|
||||
private fun cacheRoot(): Path {
|
||||
val home = System.getProperty("user.home") ?: "/tmp"
|
||||
val root = Path.of(home, ".cache", "amethyst-nests-interop")
|
||||
Files.createDirectories(root)
|
||||
return root
|
||||
}
|
||||
|
||||
private fun ensureRepo(): File {
|
||||
val rev = System.getProperty("nestsInteropRev") ?: DEFAULT_REVISION
|
||||
val target = cacheRoot().resolve("nests").toFile()
|
||||
if (!target.exists()) {
|
||||
runProcess(cacheRoot().toFile(), "git", "clone", REPO_URL, "nests")
|
||||
}
|
||||
// Always fetch + checkout the requested revision so test runs
|
||||
// are reproducible even if `main` advances.
|
||||
runProcess(target, "git", "fetch", "origin", "--quiet")
|
||||
runProcess(target, "git", "checkout", "--quiet", rev)
|
||||
return target
|
||||
}
|
||||
|
||||
private fun runDocker(
|
||||
workDir: File,
|
||||
vararg args: String,
|
||||
) {
|
||||
runProcess(workDir, "docker", "compose", "-f", COMPOSE_FILE, *args)
|
||||
}
|
||||
|
||||
private fun runProcess(
|
||||
workDir: File,
|
||||
vararg args: String,
|
||||
) {
|
||||
val process =
|
||||
ProcessBuilder(*args)
|
||||
.directory(workDir)
|
||||
.redirectErrorStream(true)
|
||||
.start()
|
||||
val output = process.inputStream.bufferedReader().readText()
|
||||
val exit = process.waitFor()
|
||||
check(exit == 0) {
|
||||
"${args.joinToString(" ")} exited with code $exit\n--- output ---\n$output"
|
||||
}
|
||||
}
|
||||
|
||||
private fun waitForPort(
|
||||
host: String,
|
||||
port: Int,
|
||||
timeoutMs: Long,
|
||||
) {
|
||||
val deadline = System.currentTimeMillis() + timeoutMs
|
||||
var lastError: Throwable? = null
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
try {
|
||||
Socket(host, port).use { return }
|
||||
} catch (t: Throwable) {
|
||||
lastError = t
|
||||
Thread.sleep(PORT_PROBE_INTERVAL_MS)
|
||||
}
|
||||
}
|
||||
throw IllegalStateException(
|
||||
"Port $host:$port did not become ready within ${timeoutMs}ms",
|
||||
lastError,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user