197 lines
11 KiB
Text
197 lines
11 KiB
Text
import Std.Http.Test.Helpers
|
|
|
|
open Std.Async
|
|
open Std Http Internal Test
|
|
|
|
-- Shared fixtures
|
|
|
|
private def ok200Head : String :=
|
|
"HTTP/1.1 200 OK\x0d\nContent-Type: text/plain; charset=utf-8\x0d\nServer: LeanHTTP/1.1\x0d\nConnection: close\x0d\nContent-Length: 2\x0d\n\x0d\n"
|
|
|
|
-- RFC 9110 §9.3.2: HEAD
|
|
|
|
#eval runGroup "RFC 9110 §9.3.2: HEAD response framing" do
|
|
check "HEAD omits body bytes, preserves headers"
|
|
(raw := "HEAD / HTTP/1.1\x0d\nHost: example.com\x0d\nConnection: close\x0d\n\x0d\n")
|
|
(handler := okHandler)
|
|
(expect := fun r => assertExact r ok200Head)
|
|
|
|
check "GET and HEAD produce identical header sections"
|
|
(raw := "GET /frame HTTP/1.1\x0d\nHost: example.com\x0d\nConnection: close\x0d\n\x0d\n")
|
|
(handler := fun _ => Response.ok |>.text "hello")
|
|
(expect := fun getResp => do
|
|
-- Run HEAD against the same handler
|
|
let (client2, server2) ← Mock.new
|
|
let headResp ← Async.block do
|
|
client2.send "HEAD /frame HTTP/1.1\x0d\nHost: example.com\x0d\nConnection: close\x0d\n\x0d\n".toUTF8
|
|
Std.Http.Server.serveConnection server2 (show TestHandler from fun _ => Response.ok |>.text "hello") defaultConfig |>.run
|
|
return (← client2.recv?).getD .empty
|
|
|
|
let getHeaders := (String.fromUTF8! getResp).splitOn "\x0d\n\x0d\n" |>.headD ""
|
|
let headHeaders := (String.fromUTF8! headResp).splitOn "\x0d\n\x0d\n" |>.headD ""
|
|
unless getHeaders == headHeaders do
|
|
throw <| IO.userError s!"headers differ:\nGET: {getHeaders.quote}\nHEAD: {headHeaders.quote}"
|
|
assertContains getResp "hello" *>
|
|
assertAbsent headResp "hello")
|
|
|
|
-- RFC 9110 §15.4: 304 and 204 response framing
|
|
|
|
#eval runGroup "RFC 9110 §15.4: 304 Not Modified strips framing headers" do
|
|
-- Direct machine test: write a 304 head with Content-Length: 5 and verify it is stripped.
|
|
-- RFC 9110 §8.6 permits Content-Length in 304 as optional metadata, but we strip it to
|
|
-- avoid forwarding a stale or wrong value from a handler that did not intend to advertise
|
|
-- a body size.
|
|
let request := "GET /cache HTTP/1.1\x0d\nHost: example.com\x0d\nConnection: close\x0d\n\x0d\n".toUTF8
|
|
let machine0 : Protocol.H1.Machine .receiving := { config := {} }
|
|
let (machine1, _) := (machine0.feed request).step
|
|
let headers304 := Headers.empty.insert Header.Name.contentLength (Header.Value.ofString! "5")
|
|
let (_, step304) := (machine1.send ({ status := .notModified, headers := headers304 } : Response.Head)).step
|
|
let text304 := String.fromUTF8! step304.output.toByteArray
|
|
unless text304.contains "HTTP/1.1 304 Not Modified" do
|
|
throw <| IO.userError s!"expected 304 status in output:\n{text304.quote}"
|
|
if text304.contains "Content-Length:" || text304.contains "Transfer-Encoding:" then
|
|
throw <| IO.userError s!"unexpected framing headers in 304:\n{text304.quote}"
|
|
|
|
#eval runGroup "RFC 9110 §15.3.5: 204 No Content strips framing headers" do
|
|
let request := "GET /empty HTTP/1.1\x0d\nHost: example.com\x0d\nConnection: close\x0d\n\x0d\n".toUTF8
|
|
let machine0 : Protocol.H1.Machine .receiving := { config := {} }
|
|
let (machine1, _) := (machine0.feed request).step
|
|
let headers204 := Headers.empty.insert Header.Name.contentLength (Header.Value.ofString! "9")
|
|
let (_, step204) := (machine1.send ({ status := .noContent, headers := headers204 } : Response.Head)).step
|
|
let text204 := String.fromUTF8! step204.output.toByteArray
|
|
unless step204.output.size > 0 do
|
|
throw <| IO.userError "expected serialized response output"
|
|
unless text204.contains "HTTP/1.1 204 No Content" do
|
|
throw <| IO.userError s!"expected 204 status:\n{text204.quote}"
|
|
if text204.contains "Content-Length:" || text204.contains "Transfer-Encoding:" then
|
|
throw <| IO.userError s!"unexpected framing headers in 204:\n{text204.quote}"
|
|
|
|
-- RFC 9112 §9.6: Client-mode — parsing responses
|
|
|
|
#eval runGroup "RFC 9112 §9.6: client-mode response parsing" do
|
|
-- Parse a 200 response with headers
|
|
let machineA : Protocol.H1.Machine .sending := { config := {}, reader := { state := .needStartLine } }
|
|
let rawA := "HTTP/1.1 200 OK\x0d\nContent-Length: 0\x0d\nConnection: close\x0d\n\x0d\n"
|
|
let (machineA', stepA) := (machineA.feed rawA.toUTF8).step
|
|
if stepA.events.any (fun | .failed _ => true | _ => false) then
|
|
throw <| IO.userError s!"unexpected failure parsing 200 response: {repr stepA.events}"
|
|
unless stepA.events.any (fun | .endHeaders _ => true | _ => false) do
|
|
throw <| IO.userError s!"missing endHeaders event: {repr stepA.events}"
|
|
unless machineA'.reader.messageHead.status == .ok do
|
|
throw <| IO.userError s!"unexpected status: {repr machineA'.reader.messageHead.status}"
|
|
unless machineA'.reader.messageHead.headers.hasEntry Header.Name.contentLength (Header.Value.ofString! "0") do
|
|
throw <| IO.userError "missing Content-Length header in parsed response"
|
|
|
|
-- Parse headerless 204
|
|
let machineB : Protocol.H1.Machine .sending := { config := {}, reader := { state := .needStartLine } }
|
|
let rawB := "HTTP/1.1 204 No Content\x0d\n\x0d\n"
|
|
let (_, stepB) := (machineB.feed rawB.toUTF8).step
|
|
if stepB.events.any (fun | .failed _ => true | _ => false) then
|
|
throw <| IO.userError s!"unexpected failure parsing 204: {repr stepB.events}"
|
|
if stepB.events.any (fun | .needMoreData _ => true | _ => false) then
|
|
throw <| IO.userError s!"unexpected needMoreData for 204: {repr stepB.events}"
|
|
unless stepB.events.any (fun | .endHeaders _ => true | _ => false) do
|
|
throw <| IO.userError s!"missing endHeaders for 204: {repr stepB.events}"
|
|
|
|
-- 204 with Content-Length in response: body framing should be ignored
|
|
let machineC : Protocol.H1.Machine .sending := { config := {}, reader := { state := .needStartLine } }
|
|
let rawC := "HTTP/1.1 204 No Content\x0d\nContent-Length: 5\x0d\n\x0d\nHELLO"
|
|
let (machineC', stepC) := (machineC.feed rawC.toUTF8).step
|
|
if stepC.events.any (fun | .failed _ => true | _ => false) then
|
|
throw <| IO.userError s!"unexpected failure for 204 with framing: {repr stepC.events}"
|
|
-- The 5 bytes of "HELLO" should remain unread
|
|
unless machineC'.reader.input.remainingBytes == 5 do
|
|
throw <| IO.userError s!"expected 5 unread bytes, got {machineC'.reader.input.remainingBytes}"
|
|
|
|
-- RFC 9110 §15.2: 1xx informational responses MUST NOT carry framing headers
|
|
|
|
#eval runGroup "RFC 9110 §15.2: 1xx informational responses strip framing headers" do
|
|
let request := "GET / HTTP/1.1\x0d\nHost: example.com\x0d\nConnection: close\x0d\n\x0d\n".toUTF8
|
|
let machine0 : Protocol.H1.Machine .receiving := { config := {} }
|
|
let (machine1, _) := (machine0.feed request).step
|
|
|
|
-- 100 Continue: handler-set Content-Length must be stripped
|
|
let headers100 := Headers.empty
|
|
|>.insert Header.Name.contentLength (Header.Value.ofString! "5")
|
|
let (machine2, step100) := (machine1.send ({ status := .«continue», headers := headers100 } : Response.Head)).step
|
|
let text100 := String.fromUTF8! step100.output.toByteArray
|
|
unless text100.contains "HTTP/1.1 100 Continue" do
|
|
throw <| IO.userError s!"expected 100 status in output:\n{text100.quote}"
|
|
if text100.contains "Content-Length:" then
|
|
throw <| IO.userError s!"Content-Length must not appear in 1xx output:\n{text100.quote}"
|
|
|
|
-- 103 Early Hints: both Content-Length and Transfer-Encoding must be stripped
|
|
let headers103 := Headers.empty
|
|
|>.insert Header.Name.contentLength (Header.Value.ofString! "42")
|
|
|>.insert Header.Name.transferEncoding (Header.Value.ofString! "chunked")
|
|
let (machine3, step103) := (machine2.send ({ status := .earlyHints, headers := headers103 } : Response.Head)).step
|
|
let text103 := String.fromUTF8! step103.output.toByteArray
|
|
unless text103.contains "HTTP/1.1 103 Early Hints" do
|
|
throw <| IO.userError s!"expected 103 status in output:\n{text103.quote}"
|
|
if text103.contains "Content-Length:" || text103.contains "Transfer-Encoding:" then
|
|
throw <| IO.userError s!"framing headers must not appear in 1xx output:\n{text103.quote}"
|
|
|
|
-- Machine must remain in waitingHeaders after sending 1xx (interim does not advance writer)
|
|
unless machine3.writer.state == .waitingHeaders do
|
|
throw <| IO.userError s!"writer must stay in waitingHeaders after 1xx, got: {repr machine3.writer.state}"
|
|
|
|
-- Final 200 OK still works after chained 1xx responses
|
|
let headers200 := Headers.empty
|
|
|>.insert Header.Name.contentLength (Header.Value.ofString! "0")
|
|
let (_, step200) := (machine3.send ({ status := .ok, headers := headers200 } : Response.Head)).step
|
|
let text200 := String.fromUTF8! step200.output.toByteArray
|
|
unless text200.contains "HTTP/1.1 200 OK" do
|
|
throw <| IO.userError s!"expected 200 after 1xx chain:\n{text200.quote}"
|
|
unless text200.contains "Content-Length: 0" do
|
|
throw <| IO.userError s!"Content-Length must be preserved in final response:\n{text200.quote}"
|
|
|
|
-- RFC 7230 §3.3.1 / RFC 9112 §6.1: HTTP/1.0 connection-close framing.
|
|
-- When the handler does not set Content-Length for an HTTP/1.0 request the machine
|
|
-- must not emit Transfer-Encoding or Content-Length; it writes raw bytes and closes.
|
|
|
|
#eval runGroup "RFC 7230 §3.3.1: HTTP/1.0 connection-close — headers" do
|
|
let request10 := "GET / HTTP/1.0\x0d\nHost: example.com\x0d\n\x0d\n".toUTF8
|
|
let machine0 : Protocol.H1.Machine .receiving := { config := {} }
|
|
let (machine1, _) := (machine0.feed request10).step
|
|
let (_, stepA) := (machine1.send ({ status := .ok, headers := .empty } : Response.Head)).step
|
|
let textA := String.fromUTF8! stepA.output.toByteArray
|
|
unless textA.contains "200 OK" do
|
|
throw <| IO.userError s!"expected 200 status line:\n{textA.quote}"
|
|
if textA.contains "Transfer-Encoding:" then
|
|
throw <| IO.userError s!"Transfer-Encoding must not appear in HTTP/1.0 response:\n{textA.quote}"
|
|
if textA.contains "Content-Length:" then
|
|
throw <| IO.userError s!"Content-Length must not appear when body length is unknown:\n{textA.quote}"
|
|
|
|
#eval runGroup "RFC 7230 §3.3.1: HTTP/1.0 connection-close — body framing" do
|
|
let request10 := "GET / HTTP/1.0\x0d\nHost: example.com\x0d\n\x0d\n".toUTF8
|
|
let machine0 : Protocol.H1.Machine .receiving := { config := {} }
|
|
let (machine1, _) := (machine0.feed request10).step
|
|
|
|
-- Non-empty body: raw bytes must appear in output without chunk framing.
|
|
let body := "hello world".toUTF8
|
|
let machine2 :=
|
|
machine1
|
|
|>.send ({ status := .ok, headers := .empty } : Response.Head)
|
|
|>.sendData #[{ data := body, extensions := #[] }]
|
|
|>.userClosedBody
|
|
let (machine3, step2) := machine2.step
|
|
let output2 := String.fromUTF8! step2.output.toByteArray
|
|
unless output2.contains "hello world" do
|
|
throw <| IO.userError s!"body bytes must appear in output:\n{output2.quote}"
|
|
-- Chunk framing would look like "b\r\nhello world\r\n0\r\n\r\n"
|
|
if output2.contains "0\x0d\x0a\x0d\x0a" then
|
|
throw <| IO.userError s!"body must not be chunk-framed (found final-chunk terminator):\n{output2.quote}"
|
|
unless step2.events.any (fun | .close => true | _ => false) do
|
|
throw <| IO.userError s!"expected .close event after connection-close body:\n{repr step2.events}"
|
|
unless !machine3.keepAlive do
|
|
throw <| IO.userError "keepAlive must be false for HTTP/1.0 connection-close response"
|
|
|
|
-- Empty body: userClosedBody with no data must still emit .close.
|
|
let (machine1b, _) := (machine0.feed request10).step
|
|
let machine4 :=
|
|
machine1b
|
|
|>.send ({ status := .ok, headers := .empty } : Response.Head)
|
|
|>.userClosedBody
|
|
let (_, step3) := machine4.step
|
|
unless step3.events.any (fun | .close => true | _ => false) do
|
|
throw <| IO.userError s!"expected .close event for empty HTTP/1.0 body:\n{repr step3.events}"
|