feat(quic-interop): add transfer / multiplexing / http3 testcases via H3 GET client
Adds a minimal Http3GetClient (in :quic-interop, NOT :quic — interop-test
surface, not a production HTTP/3 client) that opens the three required
client uni streams (control + QPACK encoder + QPACK decoder per RFC 9114
§6.2.1), sends an empty SETTINGS, and per request opens a bidi stream,
encodes the four pseudo-headers via the existing literal-only QpackEncoder,
FINs, and reassembles HEADERS+DATA frames from the response.
Wires three testcases:
- transfer: GET each REQUESTS URL sequentially, write body to
\$DOWNLOADS/<basename>. status != 200 fails.
- http3: identical to transfer.
- multiplexing: same fetches but issued in parallel via
coroutineScope { async { … } } so request streams genuinely
overlap on the wire (what tshark verifies).
Out-of-scope (deliberate): GOAWAY, PUSH_PROMISE, dynamic QPACK table,
trailers, priority — none are required by these testcases.
Unit-tests round-trip the request encoding through the existing
Http3FrameReader + QpackDecoder so the wire format is verified without
needing a real peer.
https://claude.ai/code/session_01HcvfQq1ttPV9PkRoJb4nyT
This commit is contained in:
@@ -47,10 +47,26 @@ Inspect `./logs/<run>/client_qlog/*.qlog` in qvis when something breaks.
|
|||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| 0 | Minimum harness | `handshake` | one test reproducible end-to-end ✅ |
|
| 0 | Minimum harness | `handshake` | one test reproducible end-to-end ✅ |
|
||||||
| 1 | Triangulate handshake bugs | + `versionnegotiation`, `chacha20` | green vs aioquic + quiche + picoquic |
|
| 1 | Triangulate handshake bugs | + `versionnegotiation`, `chacha20` | green vs aioquic + quiche + picoquic |
|
||||||
| 2 | Streams + loss + multiplexing | + `transfer`, `multiplexing`, `*loss`, `http3` | green vs aioquic + quiche; soak 500/500 |
|
| 2 | Streams + loss + multiplexing | + `transfer`, `multiplexing`, `*loss`, `http3` | `transfer` / `multiplexing` / `http3` ✅ landed; loss tests pending |
|
||||||
| 3 | Edge cases | `retry`, `resumption`, `zerortt`, `keyupdate`, `rebinding-*`, `blackhole`, `amplificationlimit` | every test green or unsupported-127 with a written reason |
|
| 3 | Edge cases | `retry`, `resumption`, `zerortt`, `keyupdate`, `rebinding-*`, `blackhole`, `amplificationlimit` | every test green or unsupported-127 with a written reason |
|
||||||
| 4 | CI gate | nightly Phases 1–2; PR-blocking subset on every push | qlogs uploaded as artifacts on red |
|
| 4 | CI gate | nightly Phases 1–2; PR-blocking subset on every push | qlogs uploaded as artifacts on red |
|
||||||
|
|
||||||
|
## Phase 2 — landed 2026-05-06
|
||||||
|
|
||||||
|
- Minimal `Http3GetClient` (in `:quic-interop`, NOT `:quic` — interop-test
|
||||||
|
surface, not a production HTTP/3 client). Opens the three required
|
||||||
|
client uni streams (control + QPACK encoder + QPACK decoder), sends
|
||||||
|
empty SETTINGS, then per request opens a bidi stream, encodes a HEADERS
|
||||||
|
frame with the four pseudo-headers using the existing literal-only
|
||||||
|
`QpackEncoder`, FINs, and reassembles HEADERS+DATA frames from the
|
||||||
|
response. Out-of-scope: GOAWAY, PUSH_PROMISE, dynamic QPACK table,
|
||||||
|
trailers, priority.
|
||||||
|
- `transfer` + `http3` testcases: GET each URL in `REQUESTS` sequentially,
|
||||||
|
write each body to `$DOWNLOADS/<basename>`. Status != 200 fails.
|
||||||
|
- `multiplexing` testcase: same as `transfer` but issues each GET in a
|
||||||
|
parallel `coroutineScope { async { … } }` so the request streams
|
||||||
|
genuinely overlap on the wire (what tshark verifies).
|
||||||
|
|
||||||
## Phase 1a — landed 2026-05-06
|
## Phase 1a — landed 2026-05-06
|
||||||
|
|
||||||
- `SSLKEYLOGFILE` writer in `InteropClient` (NSS Key Log Format) so Wireshark
|
- `SSLKEYLOGFILE` writer in `InteropClient` (NSS Key Log Format) so Wireshark
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
/*
|
||||||
|
* 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.quic.interop.runner
|
||||||
|
|
||||||
|
import com.vitorpamplona.quic.QuicWriter
|
||||||
|
import com.vitorpamplona.quic.connection.QuicConnection
|
||||||
|
import com.vitorpamplona.quic.http3.Http3Frame
|
||||||
|
import com.vitorpamplona.quic.http3.Http3FrameReader
|
||||||
|
import com.vitorpamplona.quic.http3.Http3FrameType
|
||||||
|
import com.vitorpamplona.quic.http3.Http3Settings
|
||||||
|
import com.vitorpamplona.quic.http3.Http3StreamType
|
||||||
|
import com.vitorpamplona.quic.qpack.QpackDecoder
|
||||||
|
import com.vitorpamplona.quic.qpack.QpackEncoder
|
||||||
|
import kotlinx.coroutines.flow.collect
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal HTTP/3 GET client used by the interop endpoint to satisfy the
|
||||||
|
* `transfer`, `multiplexing`, and `http3` testcases.
|
||||||
|
*
|
||||||
|
* Opens the three required client-side unidirectional streams (control,
|
||||||
|
* QPACK encoder, QPACK decoder) per RFC 9114 §6.2.1. Encodes requests with
|
||||||
|
* the literal-only [QpackEncoder] (no dynamic table — RFC 9204 Required
|
||||||
|
* Insert Count = 0) so we don't need to push QPACK encoder instructions.
|
||||||
|
*
|
||||||
|
* Not a production HTTP/3 client. Specifically: no GOAWAY handling, no
|
||||||
|
* priority, no push-promise, no trailers, no dynamic QPACK table.
|
||||||
|
*/
|
||||||
|
class Http3GetClient(
|
||||||
|
private val conn: QuicConnection,
|
||||||
|
) {
|
||||||
|
suspend fun init() {
|
||||||
|
// Control stream: type-0x00 prefix followed by a SETTINGS frame
|
||||||
|
// (empty body is legal — RFC 9114 §7.2.4).
|
||||||
|
val control = conn.openUniStream()
|
||||||
|
val w = QuicWriter()
|
||||||
|
w.writeVarint(Http3StreamType.CONTROL)
|
||||||
|
w.writeBytes(Http3Settings(emptyMap()).encodeFrame())
|
||||||
|
control.send.enqueue(w.toByteArray())
|
||||||
|
// Control stream stays open for the lifetime of the H3 connection;
|
||||||
|
// do NOT call finish() — peers treat that as H3_CLOSED_CRITICAL_STREAM.
|
||||||
|
|
||||||
|
// Required: open QPACK encoder + decoder streams, even though we
|
||||||
|
// never insert into the dynamic table. Just send the type prefix.
|
||||||
|
val qpackEnc = conn.openUniStream()
|
||||||
|
val w2 = QuicWriter()
|
||||||
|
w2.writeVarint(Http3StreamType.QPACK_ENCODER)
|
||||||
|
qpackEnc.send.enqueue(w2.toByteArray())
|
||||||
|
|
||||||
|
val qpackDec = conn.openUniStream()
|
||||||
|
val w3 = QuicWriter()
|
||||||
|
w3.writeVarint(Http3StreamType.QPACK_DECODER)
|
||||||
|
qpackDec.send.enqueue(w3.toByteArray())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Issue a GET on a fresh bidi stream and return the parsed response.
|
||||||
|
* Suspends until the server FINs the response stream.
|
||||||
|
*/
|
||||||
|
suspend fun get(
|
||||||
|
authority: String,
|
||||||
|
path: String,
|
||||||
|
): Response {
|
||||||
|
val stream = conn.openBidiStream()
|
||||||
|
stream.send.enqueue(encodeRequest(authority, path))
|
||||||
|
stream.send.finish()
|
||||||
|
|
||||||
|
val reader = Http3FrameReader()
|
||||||
|
var status = 0
|
||||||
|
val body = mutableListOf<ByteArray>()
|
||||||
|
stream.incoming.collect { chunk ->
|
||||||
|
reader.push(chunk)
|
||||||
|
while (true) {
|
||||||
|
val frame = reader.next() ?: break
|
||||||
|
when (frame) {
|
||||||
|
is Http3Frame.Headers -> {
|
||||||
|
val fields = QpackDecoder().decodeFieldSection(frame.qpackPayload)
|
||||||
|
status = fields.firstOrNull { it.first == ":status" }?.second?.toIntOrNull() ?: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
is Http3Frame.Data -> {
|
||||||
|
body += frame.body
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Response(status = status, body = concat(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
data class Response(
|
||||||
|
val status: Int,
|
||||||
|
val body: ByteArray,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serialize a GET request as a single HEADERS frame ready to be enqueued
|
||||||
|
* onto a fresh bidi stream. Exposed for unit-testing the wire format
|
||||||
|
* without spinning up a QUIC connection.
|
||||||
|
*/
|
||||||
|
internal fun encodeRequest(
|
||||||
|
authority: String,
|
||||||
|
path: String,
|
||||||
|
): ByteArray {
|
||||||
|
val headers =
|
||||||
|
listOf(
|
||||||
|
":method" to "GET",
|
||||||
|
":scheme" to "https",
|
||||||
|
":authority" to authority,
|
||||||
|
":path" to path,
|
||||||
|
)
|
||||||
|
val qpack = QpackEncoder().encodeFieldSection(headers)
|
||||||
|
val w = QuicWriter()
|
||||||
|
w.writeVarint(Http3FrameType.HEADERS)
|
||||||
|
w.writeVarint(qpack.size.toLong())
|
||||||
|
w.writeBytes(qpack)
|
||||||
|
return w.toByteArray()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun concat(parts: List<ByteArray>): ByteArray {
|
||||||
|
val total = parts.sumOf { it.size }
|
||||||
|
val out = ByteArray(total)
|
||||||
|
var off = 0
|
||||||
|
for (p in parts) {
|
||||||
|
p.copyInto(out, off)
|
||||||
|
off += p.size
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
+132
-2
@@ -30,7 +30,9 @@ import com.vitorpamplona.quic.transport.UdpSocket
|
|||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.async
|
||||||
import kotlinx.coroutines.cancel
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.coroutineScope
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import kotlinx.coroutines.withTimeoutOrNull
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
@@ -53,6 +55,7 @@ private const val EXIT_FAIL = 1
|
|||||||
private const val EXIT_UNSUPPORTED = 127
|
private const val EXIT_UNSUPPORTED = 127
|
||||||
|
|
||||||
private const val HANDSHAKE_TIMEOUT_SEC = 10L
|
private const val HANDSHAKE_TIMEOUT_SEC = 10L
|
||||||
|
private const val TRANSFER_TIMEOUT_SEC = 30L
|
||||||
|
|
||||||
fun main() {
|
fun main() {
|
||||||
val role = System.getenv("ROLE") ?: "client"
|
val role = System.getenv("ROLE") ?: "client"
|
||||||
@@ -63,11 +66,13 @@ fun main() {
|
|||||||
|
|
||||||
val testcase = System.getenv("TESTCASE")?.trim().orEmpty()
|
val testcase = System.getenv("TESTCASE")?.trim().orEmpty()
|
||||||
val requests = System.getenv("REQUESTS")?.trim().orEmpty()
|
val requests = System.getenv("REQUESTS")?.trim().orEmpty()
|
||||||
|
val downloads = System.getenv("DOWNLOADS")?.takeIf { it.isNotBlank() }
|
||||||
val keyLogPath = System.getenv("SSLKEYLOGFILE")?.takeIf { it.isNotBlank() }
|
val keyLogPath = System.getenv("SSLKEYLOGFILE")?.takeIf { it.isNotBlank() }
|
||||||
|
|
||||||
System.err.println("== quic-interop client ==")
|
System.err.println("== quic-interop client ==")
|
||||||
System.err.println("testcase: $testcase")
|
System.err.println("testcase: $testcase")
|
||||||
System.err.println("requests: $requests")
|
System.err.println("requests: $requests")
|
||||||
|
System.err.println("downloads: ${downloads ?: "(unset)"}")
|
||||||
System.err.println("sslkeylogfile: ${keyLogPath ?: "(unset)"}")
|
System.err.println("sslkeylogfile: ${keyLogPath ?: "(unset)"}")
|
||||||
|
|
||||||
val cipherSuites =
|
val cipherSuites =
|
||||||
@@ -82,9 +87,32 @@ fun main() {
|
|||||||
// complete; `chacha20` adds the constraint that we offered only
|
// complete; `chacha20` adds the constraint that we offered only
|
||||||
// ChaCha20-Poly1305 (verified by the runner via tshark on the
|
// ChaCha20-Poly1305 (verified by the runner via tshark on the
|
||||||
// sim's pcap, decrypted using SSLKEYLOGFILE).
|
// sim's pcap, decrypted using SSLKEYLOGFILE).
|
||||||
"handshake", "chacha20" -> runHandshakeTest(requests, cipherSuites, keyLogPath)
|
"handshake", "chacha20" -> {
|
||||||
|
runHandshakeTest(requests, cipherSuites, keyLogPath)
|
||||||
|
}
|
||||||
|
|
||||||
else -> EXIT_UNSUPPORTED
|
// `transfer` and `http3` both fetch every URL in REQUESTS and
|
||||||
|
// write each body to $DOWNLOADS/<basename>. `multiplexing`
|
||||||
|
// additionally requires the requests to be sent in parallel on
|
||||||
|
// separate streams (the runner verifies via tshark that the
|
||||||
|
// streams overlap in time).
|
||||||
|
"transfer", "http3", "multiplexing" -> {
|
||||||
|
if (downloads == null) {
|
||||||
|
System.err.println("DOWNLOADS env var required for $testcase")
|
||||||
|
EXIT_FAIL
|
||||||
|
} else {
|
||||||
|
runTransferTest(
|
||||||
|
requests = requests,
|
||||||
|
downloadsDir = File(downloads),
|
||||||
|
keyLogPath = keyLogPath,
|
||||||
|
parallel = (testcase == "multiplexing"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
EXIT_UNSUPPORTED
|
||||||
|
}
|
||||||
}
|
}
|
||||||
exitProcess(code)
|
exitProcess(code)
|
||||||
}
|
}
|
||||||
@@ -154,6 +182,108 @@ private fun runHandshakeTest(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun runTransferTest(
|
||||||
|
requests: String,
|
||||||
|
downloadsDir: File,
|
||||||
|
keyLogPath: String?,
|
||||||
|
parallel: Boolean,
|
||||||
|
): Int {
|
||||||
|
val urls =
|
||||||
|
requests
|
||||||
|
.split(Regex("\\s+"))
|
||||||
|
.filter { it.isNotBlank() }
|
||||||
|
.map { runCatching { URI(it) }.getOrNull() }
|
||||||
|
.filter { it != null && it.host != null }
|
||||||
|
.map { it!! }
|
||||||
|
if (urls.isEmpty()) {
|
||||||
|
System.err.println("no parseable URL in REQUESTS")
|
||||||
|
return EXIT_FAIL
|
||||||
|
}
|
||||||
|
val first = urls[0]
|
||||||
|
val host = first.host
|
||||||
|
val port = first.port.takeIf { it > 0 } ?: 443
|
||||||
|
|
||||||
|
downloadsDir.mkdirs()
|
||||||
|
|
||||||
|
val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
val outcome =
|
||||||
|
runBlocking {
|
||||||
|
val socket =
|
||||||
|
try {
|
||||||
|
UdpSocket.connect(host, port)
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
return@runBlocking "udp_failed: ${t.message ?: t::class.simpleName}"
|
||||||
|
}
|
||||||
|
val keyLogger = keyLogPath?.let { SslKeyLogger(File(it)) }
|
||||||
|
val conn =
|
||||||
|
QuicConnection(
|
||||||
|
serverName = host,
|
||||||
|
config = QuicConnectionConfig(),
|
||||||
|
tlsCertificateValidator = PermissiveCertificateValidator(),
|
||||||
|
extraSecretsListener = keyLogger?.listener,
|
||||||
|
)
|
||||||
|
val driver = QuicConnectionDriver(conn, socket, scope)
|
||||||
|
driver.start()
|
||||||
|
|
||||||
|
val handshake =
|
||||||
|
withTimeoutOrNull(HANDSHAKE_TIMEOUT_SEC * 1_000L) {
|
||||||
|
runCatching { conn.awaitHandshake() }
|
||||||
|
}
|
||||||
|
if (handshake == null || handshake.isFailure) {
|
||||||
|
runCatching { driver.close() }
|
||||||
|
conn.tls.clientRandom?.let { keyLogger?.flush(it) }
|
||||||
|
return@runBlocking "handshake_failed"
|
||||||
|
}
|
||||||
|
|
||||||
|
val client = Http3GetClient(conn)
|
||||||
|
client.init()
|
||||||
|
|
||||||
|
val authority = if (port == 443) host else "$host:$port"
|
||||||
|
val outcome =
|
||||||
|
withTimeoutOrNull(TRANSFER_TIMEOUT_SEC * 1_000L) {
|
||||||
|
val responses =
|
||||||
|
if (parallel) {
|
||||||
|
// Open every request stream up-front so they
|
||||||
|
// genuinely overlap on the wire — what the
|
||||||
|
// multiplexing testcase verifies.
|
||||||
|
coroutineScope {
|
||||||
|
urls
|
||||||
|
.map { url -> async { url to client.get(authority, url.path) } }
|
||||||
|
.map { it.await() }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
urls.map { url -> url to client.get(authority, url.path) }
|
||||||
|
}
|
||||||
|
var anyFailed = false
|
||||||
|
for ((url, resp) in responses) {
|
||||||
|
if (resp.status != 200) {
|
||||||
|
System.err.println("GET ${url.path} → status ${resp.status}")
|
||||||
|
anyFailed = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
val name = url.path.substringAfterLast('/').ifBlank { "index" }
|
||||||
|
File(downloadsDir, name).writeBytes(resp.body)
|
||||||
|
System.err.println("GET ${url.path} → ${resp.body.size} bytes")
|
||||||
|
}
|
||||||
|
if (anyFailed) "request_failed" else "ok"
|
||||||
|
} ?: "transfer_timeout"
|
||||||
|
|
||||||
|
runCatching { driver.close() }
|
||||||
|
conn.tls.clientRandom?.let { keyLogger?.flush(it) }
|
||||||
|
delay(50)
|
||||||
|
outcome
|
||||||
|
}
|
||||||
|
scope.cancel()
|
||||||
|
|
||||||
|
return if (outcome == "ok") {
|
||||||
|
System.err.println("transfer ok")
|
||||||
|
EXIT_OK
|
||||||
|
} else {
|
||||||
|
System.err.println("transfer $outcome")
|
||||||
|
EXIT_FAIL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun parseFirstTarget(requests: String): Pair<String, Int>? {
|
private fun parseFirstTarget(requests: String): Pair<String, Int>? {
|
||||||
val first = requests.split(Regex("\\s+")).firstOrNull { it.isNotBlank() } ?: return null
|
val first = requests.split(Regex("\\s+")).firstOrNull { it.isNotBlank() } ?: return null
|
||||||
val uri =
|
val uri =
|
||||||
|
|||||||
+59
@@ -0,0 +1,59 @@
|
|||||||
|
/*
|
||||||
|
* 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.quic.interop.runner
|
||||||
|
|
||||||
|
import com.vitorpamplona.quic.http3.Http3Frame
|
||||||
|
import com.vitorpamplona.quic.http3.Http3FrameReader
|
||||||
|
import com.vitorpamplona.quic.qpack.QpackDecoder
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
class Http3GetClientTest {
|
||||||
|
@Test
|
||||||
|
fun `encodeRequest produces a single HEADERS frame with the four pseudo-headers`() {
|
||||||
|
val bytes = encodeRequest(authority = "example.com", path = "/file.bin")
|
||||||
|
|
||||||
|
val reader = Http3FrameReader().apply { push(bytes) }
|
||||||
|
val frame = reader.next()
|
||||||
|
assertTrue(frame is Http3Frame.Headers, "first frame must be HEADERS, got $frame")
|
||||||
|
assertEquals(null, reader.next(), "should be exactly one frame")
|
||||||
|
|
||||||
|
val fields = QpackDecoder().decodeFieldSection(frame.qpackPayload)
|
||||||
|
// QPACK emits headers in order; confirm pseudo-headers come first
|
||||||
|
// and carry the expected values.
|
||||||
|
val map = fields.associate { it.first to it.second }
|
||||||
|
assertEquals("GET", map[":method"])
|
||||||
|
assertEquals("https", map[":scheme"])
|
||||||
|
assertEquals("example.com", map[":authority"])
|
||||||
|
assertEquals("/file.bin", map[":path"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `encodeRequest survives an authority with a non-default port`() {
|
||||||
|
val bytes = encodeRequest(authority = "example.com:8443", path = "/")
|
||||||
|
val frame = Http3FrameReader().apply { push(bytes) }.next()
|
||||||
|
require(frame is Http3Frame.Headers)
|
||||||
|
val map = QpackDecoder().decodeFieldSection(frame.qpackPayload).associate { it.first to it.second }
|
||||||
|
assertEquals("example.com:8443", map[":authority"])
|
||||||
|
assertEquals("/", map[":path"])
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user