diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f2bcb7..0ebd7a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [2.3.3] - 2026-03-07 + +### Fixed + +- Fixed multiple `Set-Cookie` headers being collapsed into a single header during mist response conversion +- `set_header` (which replaces) was used for all headers including `Set-Cookie`, but RFC 6265 requires each cookie to be sent as a separate `Set-Cookie` header +- `add_header` now uses `prepend_header` (which allows duplicates) for `set-cookie` headers, matching the Gleam standard library's own `set_cookie` convention +- Browsers that received responses with multiple cookies now correctly receive all cookies instead of only the last one + +### Added + +- Added `count_mist_headers` test matcher for verifying header counts by name +- Added `extract_all_mist_header_values` test matcher for extracting all values of a header +- Added 7 tests covering multiple `Set-Cookie` header behavior (RFC 6265 compliance) + ## [2.3.2] - 2026-02-04 ### Added @@ -475,7 +490,8 @@ Special thanks to [Louis Pilfold](https://github.com/lpil) for suggesting the ra - All code examples now include proper imports - Improved documentation tone and consistency -[Unreleased]: https://github.com/TrustBound/dream/compare/v2.3.2...HEAD +[Unreleased]: https://github.com/TrustBound/dream/compare/v2.3.3...HEAD +[2.3.3]: https://github.com/TrustBound/dream/compare/v2.3.2...v2.3.3 [2.3.2]: https://github.com/TrustBound/dream/compare/v2.3.1...v2.3.2 [2.3.1]: https://github.com/TrustBound/dream/compare/v2.3.0...v2.3.1 [2.3.0]: https://github.com/TrustBound/dream/compare/v2.2.0...v2.3.0 diff --git a/gleam.toml b/gleam.toml index 643df45..b5f0292 100644 --- a/gleam.toml +++ b/gleam.toml @@ -1,5 +1,5 @@ name = "dream" -version = "2.3.2" +version = "2.3.3" description = "Clean, composable web development for Gleam. No magic." licences = ["MIT"] repository = { type = "github", user = "TrustBound", repo = "dream" } diff --git a/releases/release-2.3.3.md b/releases/release-2.3.3.md new file mode 100644 index 0000000..200dde0 --- /dev/null +++ b/releases/release-2.3.3.md @@ -0,0 +1,69 @@ +# Dream 2.3.3 Release Notes + +**Release Date:** March 7, 2026 + +This release fixes a bug where multiple `Set-Cookie` headers were collapsed into a single header, causing browsers to only receive the last cookie. + +## Key Highlights + +- **Set-Cookie fix**: Multiple cookies now each produce their own `Set-Cookie` header per RFC 6265 +- **Comprehensive test coverage**: 7 new tests validate multi-cookie behavior and RFC compliance + +## Fixed + +The mist response converter used `set_header` (which replaces existing headers with the same name) for every header, including `Set-Cookie`. RFC 6265 requires each cookie to be sent as a separate `Set-Cookie` header — browsers do not parse comma-separated `Set-Cookie` values. + +`add_header` now uses `prepend_header` (which allows duplicates) for `set-cookie` headers, and `set_header` (which replaces) for everything else. This matches the Gleam standard library's own `set_cookie` convention. + +### Before + +```gleam +fn add_header(acc, header) { + http_response.set_header(acc, header.0, header.1) +} +``` + +### After + +```gleam +fn add_header(acc, header) { + case header.0 { + "set-cookie" -> http_response.prepend_header(acc, header.0, header.1) + _ -> http_response.set_header(acc, header.0, header.1) + } +} +``` + +## Added + +- `count_mist_headers` test matcher for verifying header counts by name +- `extract_all_mist_header_values` test matcher for extracting all values of a header +- 7 new tests covering: + - Multiple cookies produce separate `Set-Cookie` headers + - Each cookie value is individually present + - Three cookies produce three headers + - Manual `Set-Cookie` in headers coexists with cookies from the `cookies` field + - Duplicate non-cookie headers are still deduplicated + - Cookies with attributes each get their own header + - Cookies alongside other headers don't interfere + +## Upgrading + +Update your dependencies: + +```toml +[dependencies] +dream = ">= 2.3.3 and < 3.0.0" +``` + +Then run: + +```bash +gleam deps download +``` + +## Documentation + +- [dream](https://hexdocs.pm/dream) - v2.3.3 + +--- diff --git a/src/dream/http/response.gleam b/src/dream/http/response.gleam index 4b12ed5..3bb46ca 100644 --- a/src/dream/http/response.gleam +++ b/src/dream/http/response.gleam @@ -67,7 +67,7 @@ pub type ResponseBody { /// - `status`: HTTP status code (200, 404, 500, etc.) - use constants from `dream/http/status` /// - `body`: Response body as Text, Bytes, or Stream /// - `headers`: List of HTTP headers -/// - `cookies`: List of cookies to set +/// - `cookies`: List of cookies to set (each becomes a separate `Set-Cookie` header) /// - `content_type`: Content-Type header value (automatically set by builders) /// /// ## Example diff --git a/src/dream/servers/mist/response.gleam b/src/dream/servers/mist/response.gleam index 3301371..b89bea0 100644 --- a/src/dream/servers/mist/response.gleam +++ b/src/dream/servers/mist/response.gleam @@ -25,7 +25,7 @@ import mist.{type ResponseData, Bytes as MistBytes, Chunked} /// /// - Status code (Int remains Int) /// - Headers (Dream Header to Mist tuple format) -/// - Cookies (formatted as Set-Cookie headers) +/// - Cookies (each cookie becomes its own `Set-Cookie` header per RFC 6265) /// - Body (Text/Bytes/Stream to Mist ResponseData) /// /// The conversion handles all three body types: @@ -103,7 +103,10 @@ fn add_header( acc: http_response.Response(ResponseData), header: #(String, String), ) -> http_response.Response(ResponseData) { - http_response.set_header(acc, header.0, header.1) + case header.0 { + "set-cookie" -> http_response.prepend_header(acc, header.0, header.1) + _ -> http_response.set_header(acc, header.0, header.1) + } } fn set_all_headers( diff --git a/test/dream/servers/mist/response_test.gleam b/test/dream/servers/mist/response_test.gleam index c3fa0e3..98939f9 100644 --- a/test/dream/servers/mist/response_test.gleam +++ b/test/dream/servers/mist/response_test.gleam @@ -4,9 +4,13 @@ import dream/http/cookie.{secure_cookie, simple_cookie} import dream/http/header.{Header} import dream/http/response.{Response, Text} import dream/servers/mist/response as mist_response -import dream_test/assertions/should.{contain_string, equal, or_fail_with, should} +import dream_test/assertions/should.{ + contain, contain_string, equal, or_fail_with, should, +} import dream_test/unit.{type UnitTest, describe, it} import gleam/option +import matchers/count_mist_headers.{count_mist_headers} +import matchers/extract_all_mist_header_values.{extract_all_mist_header_values} import matchers/extract_mist_header_value.{extract_mist_header_value} import matchers/have_mist_header.{have_mist_header} import matchers/have_mist_header_containing.{have_mist_header_containing} @@ -18,6 +22,7 @@ import matchers/have_mist_header_containing.{have_mist_header_containing} pub fn tests() -> UnitTest { describe("response", [ convert_tests(), + multiple_set_cookie_tests(), ]) } @@ -237,3 +242,228 @@ fn convert_tests() -> UnitTest { }), ]) } + +fn multiple_set_cookie_tests() -> UnitTest { + describe("multiple Set-Cookie headers (RFC 6265)", [ + it("produces separate Set-Cookie headers for each cookie", fn() { + // Arrange + let dream_response = + Response( + status: 200, + body: Text("OK"), + headers: [], + cookies: [ + simple_cookie("session", "abc123"), + simple_cookie("theme", "dark"), + ], + content_type: option.None, + ) + + // Act + let result = mist_response.convert(dream_response) + + // Assert + result + |> should() + |> count_mist_headers("set-cookie") + |> equal(2) + |> or_fail_with( + "Each cookie must produce its own Set-Cookie header per RFC 6265", + ) + }), + it("each cookie value is individually present", fn() { + // Arrange + let dream_response = + Response( + status: 200, + body: Text("OK"), + headers: [], + cookies: [ + simple_cookie("session", "abc123"), + simple_cookie("theme", "dark"), + ], + content_type: option.None, + ) + + // Act + let result = mist_response.convert(dream_response) + + // Assert — both cookie values appear as separate headers + result + |> should() + |> have_mist_header_containing("set-cookie", "session=abc123") + |> or_fail_with("Should have session cookie header") + + result + |> should() + |> have_mist_header_containing("set-cookie", "theme=dark") + |> or_fail_with("Should have theme cookie header") + }), + it("three cookies produce three separate Set-Cookie headers", fn() { + // Arrange + let dream_response = + Response( + status: 200, + body: Text("OK"), + headers: [], + cookies: [ + simple_cookie("a", "1"), + simple_cookie("b", "2"), + simple_cookie("c", "3"), + ], + content_type: option.None, + ) + + // Act + let result = mist_response.convert(dream_response) + + // Assert + result + |> should() + |> count_mist_headers("set-cookie") + |> equal(3) + |> or_fail_with("Three cookies must produce three Set-Cookie headers") + }), + it( + "manual Set-Cookie in headers coexists with cookies from cookies field", + fn() { + // Arrange — one cookie via headers, one via cookies field + let dream_response = + Response( + status: 200, + body: Text("OK"), + headers: [Header("Set-Cookie", "manual=fromheader; Path=/")], + cookies: [simple_cookie("session", "abc123")], + content_type: option.None, + ) + + // Act + let result = mist_response.convert(dream_response) + + // Assert — both must survive as separate headers + result + |> should() + |> count_mist_headers("set-cookie") + |> equal(2) + |> or_fail_with( + "Manual Set-Cookie header and cookie field should both be present", + ) + + result + |> should() + |> have_mist_header_containing("set-cookie", "manual=fromheader") + |> or_fail_with("Manual Set-Cookie header should be preserved") + + result + |> should() + |> have_mist_header_containing("set-cookie", "session=abc123") + |> or_fail_with("Cookie field cookie should be preserved") + }, + ), + it("duplicate non-set-cookie headers are still deduplicated", fn() { + // Arrange — two headers with the same non-cookie name + let dream_response = + Response( + status: 200, + body: Text("OK"), + headers: [ + Header("X-Request-ID", "first"), + Header("X-Request-ID", "second"), + ], + cookies: [], + content_type: option.None, + ) + + // Act + let result = mist_response.convert(dream_response) + + // Assert — set_header replaces, so only the last value should remain + result + |> should() + |> count_mist_headers("x-request-id") + |> equal(1) + |> or_fail_with( + "Non-cookie duplicate headers should be deduplicated by set_header", + ) + }), + it("cookies with attributes each get their own header", fn() { + // Arrange — mix of simple and secure cookies + let dream_response = + Response( + status: 200, + body: Text("OK"), + headers: [], + cookies: [ + simple_cookie("preferences", "lang=en"), + secure_cookie("auth_token", "secret123"), + ], + content_type: option.None, + ) + + // Act + let result = mist_response.convert(dream_response) + + // Assert — both cookies present, secure one has its attributes + result + |> should() + |> count_mist_headers("set-cookie") + |> equal(2) + |> or_fail_with("Both cookies should produce separate Set-Cookie headers") + + result + |> should() + |> have_mist_header_containing("set-cookie", "preferences=lang=en") + |> or_fail_with("Simple cookie should be present") + + result + |> should() + |> extract_all_mist_header_values("set-cookie") + |> contain("auth_token=secret123; Secure; HttpOnly; SameSite=Strict") + |> or_fail_with( + "Secure cookie should have all attributes in its own header", + ) + }), + it("cookies alongside other headers don't interfere", fn() { + // Arrange + let dream_response = + Response( + status: 200, + body: Text("OK"), + headers: [ + Header("X-Custom", "value1"), + Header("Cache-Control", "no-store"), + ], + cookies: [ + simple_cookie("session", "abc"), + simple_cookie("csrf", "token123"), + ], + content_type: option.Some("text/html"), + ) + + // Act + let result = mist_response.convert(dream_response) + + // Assert — all headers coexist correctly + result + |> should() + |> count_mist_headers("set-cookie") + |> equal(2) + |> or_fail_with("Both cookies should be present") + + result + |> should() + |> have_mist_header("x-custom", "value1") + |> or_fail_with("Custom header should be preserved") + + result + |> should() + |> have_mist_header("cache-control", "no-store") + |> or_fail_with("Cache-Control should be preserved") + + result + |> should() + |> have_mist_header("content-type", "text/html") + |> or_fail_with("Content-type should be preserved") + }), + ]) +} diff --git a/test/matchers/count_mist_headers.gleam b/test/matchers/count_mist_headers.gleam new file mode 100644 index 0000000..febe46e --- /dev/null +++ b/test/matchers/count_mist_headers.gleam @@ -0,0 +1,34 @@ +//// Custom matcher to count headers with a given name in a mist response. + +import dream_test/types.{type MatchResult, MatchFailed, MatchOk} +import gleam/http/response.{type Response} +import gleam/list +import mist.{type ResponseData} + +/// Count headers with the given name, returning the count for further assertions. +/// +/// ## Example +/// +/// ```gleam +/// mist_response.convert(dream_response) +/// |> should() +/// |> count_mist_headers("set-cookie") +/// |> equal(2) +/// |> or_fail_with("Should have 2 Set-Cookie headers") +/// ``` +/// +pub fn count_mist_headers( + result: MatchResult(Response(ResponseData)), + name: String, +) -> MatchResult(Int) { + case result { + MatchFailed(failure) -> MatchFailed(failure) + MatchOk(response) -> { + let count = + response.headers + |> list.filter(fn(header) { header.0 == name }) + |> list.length() + MatchOk(count) + } + } +} diff --git a/test/matchers/extract_all_mist_header_values.gleam b/test/matchers/extract_all_mist_header_values.gleam new file mode 100644 index 0000000..143dd98 --- /dev/null +++ b/test/matchers/extract_all_mist_header_values.gleam @@ -0,0 +1,65 @@ +//// Custom matcher to extract all values for a header name from a mist response. + +import dream_test/types.{ + type MatchResult, AssertionFailure, CustomMatcherFailure, MatchFailed, MatchOk, +} +import gleam/http/response.{type Response} +import gleam/list +import gleam/option.{Some} +import gleam/string +import mist.{type ResponseData} + +/// Extract all values for a header name, returning a list for further assertions. +/// +/// ## Example +/// +/// ```gleam +/// mist_response.convert(dream_response) +/// |> should() +/// |> extract_all_mist_header_values("set-cookie") +/// |> have_length(2) +/// |> or_fail_with("Should have 2 Set-Cookie headers") +/// ``` +/// +pub fn extract_all_mist_header_values( + result: MatchResult(Response(ResponseData)), + name: String, +) -> MatchResult(List(String)) { + case result { + MatchFailed(failure) -> MatchFailed(failure) + MatchOk(response) -> { + let values = + response.headers + |> list.filter_map(fn(header) { + case header.0 == name { + True -> Ok(header.1) + False -> Error(Nil) + } + }) + case values { + [] -> header_not_found_failure(name, response.headers) + _ -> MatchOk(values) + } + } + } +} + +fn header_not_found_failure( + name: String, + headers: List(#(String, String)), +) -> MatchResult(List(String)) { + MatchFailed(AssertionFailure( + operator: "extract_all_mist_header_values", + message: "Expected header '" <> name <> "' not found", + payload: Some(CustomMatcherFailure( + actual: format_headers(headers), + description: "Response headers", + )), + )) +} + +fn format_headers(headers: List(#(String, String))) -> String { + headers + |> list.map(fn(header) { header.0 <> ": " <> header.1 }) + |> string.join(", ") +}