diff --git a/nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/interop/NostrNestsAuthInteropTest.kt b/nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/interop/NostrNestsAuthInteropTest.kt new file mode 100644 index 000000000..92e3a4e37 --- /dev/null +++ b/nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/interop/NostrNestsAuthInteropTest.kt @@ -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 `/` with no body, + * expecting `{endpoint, token}` back) doesn't match the real server + * (POST `/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":""}` 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 + } + } +} diff --git a/nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/interop/NostrNestsHarness.kt b/nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/interop/NostrNestsHarness.kt new file mode 100644 index 000000000..a80678ba2 --- /dev/null +++ b/nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/interop/NostrNestsHarness.kt @@ -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=`. + */ + 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, + ) + } + } +}