test/refactor(audio-rooms): nostrnests wire-shape fixes + interop expansion (phase 4)
Wire-shape corrections discovered while scoping the interop test suite
against the real moq-rs relay:
1. WebTransport CONNECT path is now /<moqNamespace> (matches the
relay's claims.root prefix check). Previously hardcoded "/anon".
2. JWT travels in the ?jwt=<token> query parameter, not the
Authorization header — moq-rs only reads the query param. The
bearer-token path on QuicWebTransportFactory is now unused for
nests; left in place for non-nests WebTransport servers.
3. Harness `moqEndpoint` is the relay base URL only; the connect
helpers append /<namespace>?jwt=<token> themselves.
Interop test additions (all -DnestsInterop=true gated, default-skipped):
- NostrNestsAuthFailureInteropTest — locks in the moq-auth sidecar's
rejection paths (missing/wrong-scheme Authorization, NIP-98 signed
for the wrong URL, malformed namespace per the strict regex,
publish=true grant for any caller — sidecar does NOT gate by NIP-53
hostlist).
- NostrNestsAuthEndpointsInteropTest — /health, /.well-known/jwks.json
shape (must contain ES256/P-256), 404 on unknown route.
- NostrNestsMultiPeerInteropTest — multi-listener fan-out, multi-
speaker isolation, subscribe-before-announce. Code is wired through
production connectNestsSpeaker / connectNestsListener; will pass
once the moq-lite gap (below) is resolved.
Major finding documented in nestsClient/plans/2026-04-26-moq-lite-gap.md:
nostrnests's stack uses moq-lite (kixelated's variant), NOT IETF
draft-ietf-moq-transport which `:nestsClient` currently implements. The
two are wire-incompatible — single-string broadcast/track names vs. byte
tuples, different ANNOUNCE/SUBSCRIBE framing. The wire-shape fixes here
make the WebTransport CONNECT itself succeed, but the post-CONNECT MoQ
framing layer still needs a moq-lite codec before round-trip / multi-peer
tests can pass against real nests. Pursued as a separate phase.
This commit is contained in:
+117
@@ -0,0 +1,117 @@
|
||||
/*
|
||||
* 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 kotlinx.coroutines.runBlocking
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import org.junit.AfterClass
|
||||
import org.junit.BeforeClass
|
||||
import org.junit.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Phase-4 ancillary endpoint smoke tests for the moq-auth sidecar. Cheap
|
||||
* sanity checks for the routes we don't drive through the production
|
||||
* client, so a regression in the sidecar's deployment (a port flip, a
|
||||
* missing route, a busted CORS config) surfaces immediately rather than
|
||||
* silently when the next /auth flow fires.
|
||||
*
|
||||
* Coverage:
|
||||
* - `GET /health` returns `{"status":"ok"}` (used by Docker health
|
||||
* probes; we read it for a test-time port-readiness check too)
|
||||
* - `GET /.well-known/jwks.json` returns a JWK set with at least one
|
||||
* ES256 verification key — moq-relay polls this every 30 s
|
||||
* (`MOQ_AUTH_REFRESH_INTERVAL`) so a malformed JWKS would brick
|
||||
* authentication
|
||||
* - Unknown route returns 404 (locks down the surface)
|
||||
*
|
||||
* Skipped by default — set `-DnestsInterop=true` to enable.
|
||||
*/
|
||||
class NostrNestsAuthEndpointsInteropTest {
|
||||
@Test
|
||||
fun health_endpoint_returns_status_ok() =
|
||||
runBlocking {
|
||||
NostrNestsHarness.assumeNestsInterop()
|
||||
val harness = harnessOrNull ?: return@runBlocking
|
||||
|
||||
http.newCall(Request.Builder().url("${harness.authBaseUrl}/health").build()).execute().use { resp ->
|
||||
val body = resp.body.string()
|
||||
assertEquals(200, resp.code, "/health should be 200, got ${resp.code}: $body")
|
||||
assertTrue("\"status\"" in body && "\"ok\"" in body, "expected {\"status\":\"ok\"} JSON, got: $body")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun jwks_endpoint_returns_at_least_one_es256_signing_key() =
|
||||
runBlocking {
|
||||
NostrNestsHarness.assumeNestsInterop()
|
||||
val harness = harnessOrNull ?: return@runBlocking
|
||||
|
||||
http.newCall(Request.Builder().url("${harness.authBaseUrl}/.well-known/jwks.json").build()).execute().use { resp ->
|
||||
val body = resp.body.string()
|
||||
assertEquals(200, resp.code, "/.well-known/jwks.json should be 200, got ${resp.code}: $body")
|
||||
// Don't pull a JWK parser into tests — substring sniffing is
|
||||
// enough to assert the shape moq-relay actually consumes.
|
||||
assertTrue("\"keys\"" in body, "JWKS body must contain a `keys` array, got: $body")
|
||||
assertTrue(
|
||||
"\"kty\"" in body,
|
||||
"JWKS entry must have a `kty` (key type) field per RFC 7517, got: $body",
|
||||
)
|
||||
assertTrue(
|
||||
"\"alg\":\"ES256\"" in body || "\"crv\":\"P-256\"" in body,
|
||||
"expected ES256 / P-256 signing key (matches moq-rs claim verification), got: $body",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unknown_route_returns_404() =
|
||||
runBlocking {
|
||||
NostrNestsHarness.assumeNestsInterop()
|
||||
val harness = harnessOrNull ?: return@runBlocking
|
||||
|
||||
http.newCall(Request.Builder().url("${harness.authBaseUrl}/does-not-exist").build()).execute().use { resp ->
|
||||
assertEquals(404, resp.code, "unknown route should be 404")
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
+231
@@ -0,0 +1,231 @@
|
||||
/*
|
||||
* 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.nestsclient.NestsException
|
||||
import com.vitorpamplona.nestsclient.NestsRoomConfig
|
||||
import com.vitorpamplona.nestsclient.OkHttpNestsClient
|
||||
import com.vitorpamplona.nestsclient.nestsAuthUrl
|
||||
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
|
||||
import kotlin.test.fail
|
||||
|
||||
/**
|
||||
* Phase-4 negative-auth interop tests. Locks in the wire-level rejection
|
||||
* paths exposed by the moq-auth sidecar so a regression in our request
|
||||
* shape (header format, NIP-98 signing, body schema, namespace regex)
|
||||
* surfaces as a fast unit-of-work failure rather than a confused
|
||||
* production stack trace.
|
||||
*
|
||||
* Mirrors the audit findings in moq-auth `index.ts` — rejection responses
|
||||
* are JSON `{"error": "..."}` with HTTP 400 / 401 / 429 depending on the
|
||||
* failure mode. We assert HTTP status only (the human-readable error text
|
||||
* isn't part of the contract), but include the message in the failure
|
||||
* message for debuggability.
|
||||
*
|
||||
* Skipped by default — set `-DnestsInterop=true` to enable.
|
||||
*/
|
||||
class NostrNestsAuthFailureInteropTest {
|
||||
@Test
|
||||
fun missing_authorization_header_is_rejected_401() =
|
||||
runBlocking {
|
||||
NostrNestsHarness.assumeNestsInterop()
|
||||
val harness = harnessOrNull ?: return@runBlocking
|
||||
|
||||
val url = nestsAuthUrl(harness.authBaseUrl)
|
||||
val body = """{"namespace":"${validNamespace()}","publish":false}"""
|
||||
assertStatus(
|
||||
expected = 401,
|
||||
description = "missing Authorization header",
|
||||
request =
|
||||
Request
|
||||
.Builder()
|
||||
.url(url)
|
||||
.post(body.toRequestBody(JSON))
|
||||
.build(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun authorization_header_with_wrong_scheme_is_rejected_401() =
|
||||
runBlocking {
|
||||
NostrNestsHarness.assumeNestsInterop()
|
||||
val harness = harnessOrNull ?: return@runBlocking
|
||||
|
||||
val url = nestsAuthUrl(harness.authBaseUrl)
|
||||
val body = """{"namespace":"${validNamespace()}","publish":false}"""
|
||||
assertStatus(
|
||||
expected = 401,
|
||||
description = "Authorization header with non-Nostr scheme",
|
||||
request =
|
||||
Request
|
||||
.Builder()
|
||||
.url(url)
|
||||
.header("Authorization", "Bearer not-a-nostr-event")
|
||||
.post(body.toRequestBody(JSON))
|
||||
.build(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nip98_event_signed_for_a_different_url_is_rejected_401() =
|
||||
runBlocking {
|
||||
NostrNestsHarness.assumeNestsInterop()
|
||||
val harness = harnessOrNull ?: return@runBlocking
|
||||
|
||||
val signer = NostrSignerInternal(KeyPair())
|
||||
val realUrl = nestsAuthUrl(harness.authBaseUrl)
|
||||
val body = """{"namespace":"${validNamespace()}","publish":false}"""
|
||||
val bodyBytes = body.encodeToByteArray()
|
||||
|
||||
// NIP-98 binds the signed event to a `u` tag that the server
|
||||
// re-checks against the request URL. Sign for a *different*
|
||||
// URL so the host/path mismatch fires.
|
||||
val authHeader =
|
||||
NestsAuth.header(
|
||||
signer = signer,
|
||||
url = "https://other.example.test/auth",
|
||||
method = "POST",
|
||||
payload = bodyBytes,
|
||||
)
|
||||
|
||||
assertStatus(
|
||||
expected = 401,
|
||||
description = "NIP-98 event signed for the wrong URL",
|
||||
request =
|
||||
Request
|
||||
.Builder()
|
||||
.url(realUrl)
|
||||
.header("Authorization", authHeader)
|
||||
.post(body.toRequestBody(JSON))
|
||||
.build(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun malformed_namespace_is_rejected_400() =
|
||||
runBlocking {
|
||||
NostrNestsHarness.assumeNestsInterop()
|
||||
val harness = harnessOrNull ?: return@runBlocking
|
||||
|
||||
// Per moq-auth's regex /^nests\/\d+:[0-9a-f]{64}:[a-zA-Z0-9._-]+$/
|
||||
// — uppercase pubkey hex doesn't match the `[0-9a-f]` class.
|
||||
val signer = NostrSignerInternal(KeyPair())
|
||||
val client = OkHttpNestsClient(http = http)
|
||||
val room =
|
||||
NestsRoomConfig(
|
||||
authBaseUrl = harness.authBaseUrl,
|
||||
endpoint = harness.moqEndpoint,
|
||||
hostPubkey = "A".repeat(64),
|
||||
roomId = "regex-fuzz",
|
||||
)
|
||||
try {
|
||||
client.mintToken(room = room, publish = false, signer = signer)
|
||||
fail("expected NestsException for invalid namespace, none thrown")
|
||||
} catch (e: NestsException) {
|
||||
assertEquals(
|
||||
400,
|
||||
e.status,
|
||||
"expected 400 for namespace regex mismatch — got status=${e.status} message='${e.message}'",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun publish_true_grants_a_token_for_any_caller() =
|
||||
runBlocking {
|
||||
NostrNestsHarness.assumeNestsInterop()
|
||||
val harness = harnessOrNull ?: return@runBlocking
|
||||
|
||||
// The sidecar does NOT gate publish-vs-audience by NIP-53
|
||||
// hostlist; any user requesting `publish: true` gets a JWT
|
||||
// with `put: [<their-pubkey>]`. Lock that in so a future
|
||||
// hostlist gate doesn't sneak in unnoticed.
|
||||
val signer = NostrSignerInternal(KeyPair())
|
||||
val client = OkHttpNestsClient(http = http)
|
||||
val room =
|
||||
NestsRoomConfig(
|
||||
authBaseUrl = harness.authBaseUrl,
|
||||
endpoint = harness.moqEndpoint,
|
||||
hostPubkey = signer.pubKey,
|
||||
roomId = "publish-grant-${System.currentTimeMillis()}",
|
||||
)
|
||||
val token = client.mintToken(room = room, publish = true, signer = signer)
|
||||
assertTrue(token.count { it == '.' } == 2, "expected JWT with 3 segments, got: $token")
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val JSON = "application/json".toMediaType()
|
||||
private val http = OkHttpClient()
|
||||
|
||||
private var harnessOrNull: NostrNestsHarness? = null
|
||||
|
||||
private fun validNamespace(): String = "nests/30312:" + "0".repeat(64) + ":sample-room"
|
||||
|
||||
/**
|
||||
* Issue [request] and assert the response status equals [expected].
|
||||
* Reads the body once for the failure message — okhttp's `Response`
|
||||
* body is single-shot, so we have to materialise the string before
|
||||
* the `.use {}` block exits.
|
||||
*/
|
||||
private fun assertStatus(
|
||||
expected: Int,
|
||||
description: String,
|
||||
request: Request,
|
||||
) {
|
||||
http.newCall(request).execute().use { response ->
|
||||
val code = response.code
|
||||
val bodyText = response.body.string()
|
||||
assertEquals(
|
||||
expected,
|
||||
code,
|
||||
"$description — server returned $code with body: $bodyText",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@BeforeClass
|
||||
@JvmStatic
|
||||
fun setUpHarness() {
|
||||
if (NostrNestsHarness.isEnabled()) {
|
||||
harnessOrNull = NostrNestsHarness.start()
|
||||
}
|
||||
}
|
||||
|
||||
@AfterClass
|
||||
@JvmStatic
|
||||
fun tearDownHarness() {
|
||||
harnessOrNull?.close()
|
||||
harnessOrNull = null
|
||||
}
|
||||
}
|
||||
}
|
||||
+6
-4
@@ -106,10 +106,12 @@ class NostrNestsHarness private constructor(
|
||||
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",
|
||||
// moq-rs terminates TLS with a self-signed cert in dev —
|
||||
// production tests pair this with PermissiveCertificateValidator.
|
||||
// The path under this base is the namespace literal (per
|
||||
// moq-rs `claims.root` matching); the `:nestsClient` connect
|
||||
// helpers append `/<namespace>?jwt=<token>` themselves.
|
||||
moqEndpoint = "https://127.0.0.1:$MOQ_HOST_PORT/",
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+406
@@ -0,0 +1,406 @@
|
||||
/*
|
||||
* 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.NestsListener
|
||||
import com.vitorpamplona.nestsclient.NestsListenerState
|
||||
import com.vitorpamplona.nestsclient.NestsRoomConfig
|
||||
import com.vitorpamplona.nestsclient.NestsSpeaker
|
||||
import com.vitorpamplona.nestsclient.NestsSpeakerState
|
||||
import com.vitorpamplona.nestsclient.OkHttpNestsClient
|
||||
import com.vitorpamplona.nestsclient.audio.AudioCapture
|
||||
import com.vitorpamplona.nestsclient.audio.OpusEncoder
|
||||
import com.vitorpamplona.nestsclient.connectNestsListener
|
||||
import com.vitorpamplona.nestsclient.connectNestsSpeaker
|
||||
import com.vitorpamplona.nestsclient.moq.MoqObject
|
||||
import com.vitorpamplona.nestsclient.moq.SubscribeHandle
|
||||
import com.vitorpamplona.nestsclient.transport.QuicWebTransportFactory
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
||||
import com.vitorpamplona.quic.tls.PermissiveCertificateValidator
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.cancelAndJoin
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.take
|
||||
import kotlinx.coroutines.flow.toList
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import org.junit.AfterClass
|
||||
import org.junit.BeforeClass
|
||||
import org.junit.Test
|
||||
import kotlin.test.assertContentEquals
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Phase-4 multi-peer interop tests. Each test brings up a real
|
||||
* nostrnests stack, exercises a multi-publisher / multi-subscriber
|
||||
* scenario through the production [connectNestsSpeaker] +
|
||||
* [connectNestsListener] entry points, and asserts the relay's fan-out
|
||||
* + ordering guarantees end-to-end.
|
||||
*
|
||||
* Coverage:
|
||||
* - **Multi-listener fan-out** — 1 speaker, 2 listeners; each listener
|
||||
* receives the full frame stream.
|
||||
* - **Multi-speaker** — 2 speakers under the same room; one listener
|
||||
* subscribes to each by pubkey hex; both subscriptions deliver
|
||||
* their respective speaker's frames with no cross-talk.
|
||||
* - **Subscribe-before-announce** — listener subscribes before the
|
||||
* speaker has announced; per moq-rs the relay holds the subscribe
|
||||
* and resolves it once a publisher arrives.
|
||||
*
|
||||
* Skipped by default — set `-DnestsInterop=true` to enable.
|
||||
*/
|
||||
class NostrNestsMultiPeerInteropTest {
|
||||
@Test
|
||||
fun one_speaker_fans_out_to_two_listeners() =
|
||||
runBlocking {
|
||||
NostrNestsHarness.assumeNestsInterop()
|
||||
val harness = harnessOrNull ?: return@runBlocking
|
||||
|
||||
val room = freshRoom(harness, hostPubkey = "0".repeat(64))
|
||||
val supervisor = SupervisorJob()
|
||||
val pumpScope = CoroutineScope(supervisor + Dispatchers.IO)
|
||||
|
||||
val speakerSigner = NostrSignerInternal(KeyPair())
|
||||
val speakerRoom = room.copy(hostPubkey = speakerSigner.pubKey)
|
||||
val capture = DriverCapture()
|
||||
|
||||
val listenerSignerA = NostrSignerInternal(KeyPair())
|
||||
val listenerSignerB = NostrSignerInternal(KeyPair())
|
||||
|
||||
try {
|
||||
val speaker =
|
||||
connectSpeaker(
|
||||
scope = pumpScope,
|
||||
room = speakerRoom,
|
||||
signer = speakerSigner,
|
||||
capture = capture,
|
||||
)
|
||||
speaker.startBroadcasting()
|
||||
|
||||
val listenerA = connectListener(pumpScope, speakerRoom, listenerSignerA)
|
||||
val listenerB = connectListener(pumpScope, speakerRoom, listenerSignerB)
|
||||
|
||||
val subA = listenerA.subscribeSpeaker(speakerSigner.pubKey)
|
||||
val subB = listenerB.subscribeSpeaker(speakerSigner.pubKey)
|
||||
|
||||
val collectedA = collectFrames(pumpScope, subA, FRAMES_FANOUT)
|
||||
val collectedB = collectFrames(pumpScope, subB, FRAMES_FANOUT)
|
||||
|
||||
delay(SUBSCRIBE_SETTLE_MS)
|
||||
for (i in 0 until FRAMES_FANOUT) {
|
||||
capture.push(shortArrayOf(i.toShort()))
|
||||
delay(FRAME_SPACING_MS)
|
||||
}
|
||||
|
||||
assertFrameSequence(collectedA.await(), FRAMES_FANOUT, "listener A")
|
||||
assertFrameSequence(collectedB.await(), FRAMES_FANOUT, "listener B")
|
||||
|
||||
runCatching { subA.unsubscribe() }
|
||||
runCatching { subB.unsubscribe() }
|
||||
runCatching { listenerA.close() }
|
||||
runCatching { listenerB.close() }
|
||||
runCatching { speaker.close() }
|
||||
} finally {
|
||||
capture.stop()
|
||||
supervisor.cancelAndJoin()
|
||||
}
|
||||
Unit
|
||||
}
|
||||
|
||||
@Test
|
||||
fun two_speakers_in_same_room_deliver_independently_to_one_listener() =
|
||||
runBlocking {
|
||||
NostrNestsHarness.assumeNestsInterop()
|
||||
val harness = harnessOrNull ?: return@runBlocking
|
||||
|
||||
// Two speakers sharing the same room namespace must publish
|
||||
// under different sub-paths (relay enforces `put: [pubkey]`).
|
||||
// The room's hostPubkey gates `claims.root` — we use signer A's
|
||||
// pubkey as the hostPubkey for the namespace, but each speaker
|
||||
// mints their own JWT bound to their own pubkey via `put`.
|
||||
val signerA = NostrSignerInternal(KeyPair())
|
||||
val signerB = NostrSignerInternal(KeyPair())
|
||||
val listenerSigner = NostrSignerInternal(KeyPair())
|
||||
val room = freshRoom(harness, hostPubkey = signerA.pubKey)
|
||||
|
||||
val supervisor = SupervisorJob()
|
||||
val pumpScope = CoroutineScope(supervisor + Dispatchers.IO)
|
||||
|
||||
val captureA = DriverCapture()
|
||||
val captureB = DriverCapture()
|
||||
|
||||
try {
|
||||
val speakerA =
|
||||
connectSpeaker(pumpScope, room, signerA, captureA, encoderPrefix = "A:")
|
||||
val speakerB =
|
||||
connectSpeaker(pumpScope, room, signerB, captureB, encoderPrefix = "B:")
|
||||
speakerA.startBroadcasting()
|
||||
speakerB.startBroadcasting()
|
||||
|
||||
val listener = connectListener(pumpScope, room, listenerSigner)
|
||||
val subA = listener.subscribeSpeaker(signerA.pubKey)
|
||||
val subB = listener.subscribeSpeaker(signerB.pubKey)
|
||||
|
||||
val collectedA = collectFrames(pumpScope, subA, FRAMES_MULTI_SPEAKER)
|
||||
val collectedB = collectFrames(pumpScope, subB, FRAMES_MULTI_SPEAKER)
|
||||
|
||||
delay(SUBSCRIBE_SETTLE_MS)
|
||||
for (i in 0 until FRAMES_MULTI_SPEAKER) {
|
||||
captureA.push(shortArrayOf((i + 0).toShort()))
|
||||
captureB.push(shortArrayOf((i + 100).toShort()))
|
||||
delay(FRAME_SPACING_MS)
|
||||
}
|
||||
|
||||
val resA = collectedA.await()
|
||||
val resB = collectedB.await()
|
||||
assertNotNull(resA, "listener never received speaker-A frames")
|
||||
assertNotNull(resB, "listener never received speaker-B frames")
|
||||
assertEquals(FRAMES_MULTI_SPEAKER, resA.size, "speaker A count")
|
||||
assertEquals(FRAMES_MULTI_SPEAKER, resB.size, "speaker B count")
|
||||
|
||||
resA.forEachIndexed { idx, obj ->
|
||||
assertContentEquals(
|
||||
"A:".encodeToByteArray() + byteArrayOf(idx.toByte()),
|
||||
obj.payload,
|
||||
"speaker A frame $idx — must not contain B's payload (no cross-talk)",
|
||||
)
|
||||
}
|
||||
resB.forEachIndexed { idx, obj ->
|
||||
assertContentEquals(
|
||||
"B:".encodeToByteArray() + byteArrayOf((idx + 100).toByte()),
|
||||
obj.payload,
|
||||
"speaker B frame $idx — must not contain A's payload (no cross-talk)",
|
||||
)
|
||||
}
|
||||
|
||||
runCatching { subA.unsubscribe() }
|
||||
runCatching { subB.unsubscribe() }
|
||||
runCatching { listener.close() }
|
||||
runCatching { speakerA.close() }
|
||||
runCatching { speakerB.close() }
|
||||
} finally {
|
||||
captureA.stop()
|
||||
captureB.stop()
|
||||
supervisor.cancelAndJoin()
|
||||
}
|
||||
Unit
|
||||
}
|
||||
|
||||
@Test
|
||||
fun listener_subscribed_before_announce_receives_late_frames() =
|
||||
runBlocking {
|
||||
NostrNestsHarness.assumeNestsInterop()
|
||||
val harness = harnessOrNull ?: return@runBlocking
|
||||
|
||||
// The relay holds the SUBSCRIBE open until a publisher
|
||||
// announces. Validates moq-rs's "subscribe before announce"
|
||||
// contract end-to-end through our client.
|
||||
val speakerSigner = NostrSignerInternal(KeyPair())
|
||||
val listenerSigner = NostrSignerInternal(KeyPair())
|
||||
val room = freshRoom(harness, hostPubkey = speakerSigner.pubKey)
|
||||
|
||||
val supervisor = SupervisorJob()
|
||||
val pumpScope = CoroutineScope(supervisor + Dispatchers.IO)
|
||||
val capture = DriverCapture()
|
||||
|
||||
try {
|
||||
val listener = connectListener(pumpScope, room, listenerSigner)
|
||||
val sub = listener.subscribeSpeaker(speakerSigner.pubKey)
|
||||
val collected = collectFrames(pumpScope, sub, FRAMES_PRESUB)
|
||||
|
||||
// Wait briefly so the SUBSCRIBE is on the relay before
|
||||
// the publisher arrives — the relay should hold it.
|
||||
delay(SUBSCRIBE_SETTLE_MS)
|
||||
|
||||
val speaker = connectSpeaker(pumpScope, room, speakerSigner, capture)
|
||||
speaker.startBroadcasting()
|
||||
|
||||
// Give the announce + publisher-side subscribe matchup
|
||||
// time to plumb before pushing frames.
|
||||
delay(SUBSCRIBE_SETTLE_MS)
|
||||
for (i in 0 until FRAMES_PRESUB) {
|
||||
capture.push(shortArrayOf(i.toShort()))
|
||||
delay(FRAME_SPACING_MS)
|
||||
}
|
||||
|
||||
assertFrameSequence(collected.await(), FRAMES_PRESUB, "pre-subscribed listener")
|
||||
|
||||
runCatching { sub.unsubscribe() }
|
||||
runCatching { listener.close() }
|
||||
runCatching { speaker.close() }
|
||||
} finally {
|
||||
capture.stop()
|
||||
supervisor.cancelAndJoin()
|
||||
}
|
||||
Unit
|
||||
}
|
||||
|
||||
// ---------- Helpers ----------
|
||||
|
||||
private suspend fun connectSpeaker(
|
||||
scope: CoroutineScope,
|
||||
room: NestsRoomConfig,
|
||||
signer: NostrSignerInternal,
|
||||
capture: AudioCapture,
|
||||
encoderPrefix: String = "FRAME-",
|
||||
): NestsSpeaker {
|
||||
val speaker =
|
||||
connectNestsSpeaker(
|
||||
httpClient = http,
|
||||
transport = transport,
|
||||
scope = scope,
|
||||
room = room,
|
||||
signer = signer,
|
||||
speakerPubkeyHex = signer.pubKey,
|
||||
captureFactory = { capture },
|
||||
encoderFactory = { StubEncoder(encoderPrefix.encodeToByteArray()) },
|
||||
)
|
||||
assertTrue(
|
||||
speaker.state.value is NestsSpeakerState.Connected,
|
||||
"speaker did not reach Connected — was ${speaker.state.value}",
|
||||
)
|
||||
return speaker
|
||||
}
|
||||
|
||||
private suspend fun connectListener(
|
||||
scope: CoroutineScope,
|
||||
room: NestsRoomConfig,
|
||||
signer: NostrSignerInternal,
|
||||
): NestsListener {
|
||||
val listener =
|
||||
connectNestsListener(
|
||||
httpClient = http,
|
||||
transport = transport,
|
||||
scope = scope,
|
||||
room = room,
|
||||
signer = signer,
|
||||
)
|
||||
assertTrue(
|
||||
listener.state.value is NestsListenerState.Connected,
|
||||
"listener did not reach Connected — was ${listener.state.value}",
|
||||
)
|
||||
return listener
|
||||
}
|
||||
|
||||
private fun collectFrames(
|
||||
scope: CoroutineScope,
|
||||
sub: SubscribeHandle,
|
||||
count: Int,
|
||||
) = scope.async {
|
||||
withTimeoutOrNull(RECEIVE_TIMEOUT_MS) {
|
||||
sub.objects.take(count).toList()
|
||||
}
|
||||
}
|
||||
|
||||
private fun assertFrameSequence(
|
||||
objs: List<MoqObject>?,
|
||||
expectedCount: Int,
|
||||
who: String,
|
||||
) {
|
||||
assertNotNull(objs, "$who did not receive $expectedCount frames within ${RECEIVE_TIMEOUT_MS}ms")
|
||||
assertEquals(expectedCount, objs.size, "$who frame count")
|
||||
objs.forEachIndexed { idx, obj ->
|
||||
assertEquals(idx.toLong(), obj.objectId, "$who object id at index $idx")
|
||||
assertContentEquals(
|
||||
"FRAME-".encodeToByteArray() + byteArrayOf(idx.toByte()),
|
||||
obj.payload,
|
||||
"$who payload at index $idx",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private class DriverCapture : AudioCapture {
|
||||
private val frames = Channel<ShortArray>(capacity = Channel.UNLIMITED)
|
||||
|
||||
@Volatile private var started: Boolean = false
|
||||
|
||||
override fun start() {
|
||||
started = true
|
||||
}
|
||||
|
||||
override suspend fun readFrame(): ShortArray? {
|
||||
if (!started) return null
|
||||
return frames.receiveCatching().getOrNull()
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
frames.close()
|
||||
}
|
||||
|
||||
fun push(pcm: ShortArray) {
|
||||
frames.trySend(pcm)
|
||||
}
|
||||
}
|
||||
|
||||
private class StubEncoder(
|
||||
private val prefix: ByteArray,
|
||||
) : OpusEncoder {
|
||||
override fun encode(pcm: ShortArray): ByteArray = prefix + byteArrayOf(pcm.first().toByte())
|
||||
|
||||
override fun release() = Unit
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val FRAMES_FANOUT = 6
|
||||
private const val FRAMES_MULTI_SPEAKER = 6
|
||||
private const val FRAMES_PRESUB = 4
|
||||
private const val SUBSCRIBE_SETTLE_MS = 500L
|
||||
private const val FRAME_SPACING_MS = 25L
|
||||
private const val RECEIVE_TIMEOUT_MS = 15_000L
|
||||
|
||||
private val http = OkHttpNestsClient()
|
||||
private val transport =
|
||||
QuicWebTransportFactory(certificateValidator = PermissiveCertificateValidator())
|
||||
private var harnessOrNull: NostrNestsHarness? = null
|
||||
|
||||
private fun freshRoom(
|
||||
harness: NostrNestsHarness,
|
||||
hostPubkey: String,
|
||||
) = NestsRoomConfig(
|
||||
authBaseUrl = harness.authBaseUrl,
|
||||
endpoint = harness.moqEndpoint,
|
||||
hostPubkey = hostPubkey,
|
||||
roomId = "mp-${System.nanoTime()}",
|
||||
)
|
||||
|
||||
@BeforeClass
|
||||
@JvmStatic
|
||||
fun setUpHarness() {
|
||||
if (NostrNestsHarness.isEnabled()) {
|
||||
harnessOrNull = NostrNestsHarness.start()
|
||||
}
|
||||
}
|
||||
|
||||
@AfterClass
|
||||
@JvmStatic
|
||||
fun tearDownHarness() {
|
||||
harnessOrNull?.close()
|
||||
harnessOrNull = null
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user