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:
Claude
2026-04-26 14:24:44 +00:00
parent 2a932dc974
commit 3283d302fa
2 changed files with 326 additions and 0 deletions
@@ -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
}
}
}
@@ -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,
)
}
}
}