1import { assert, describe, it } from "@effect/vitest"
2import { assertNone, assertSome, deepStrictEqual } from "@effect/vitest/utils"
3import { Result, Schema } from "effect"
4import { Cookies } from "effect/http"
5import * as Option from "effect/Option"
6import { TestSchema } from "effect/testing"
7import { assertFailure, assertSuccess } from "../utils/assert.ts"
8
9describe("Cookies", () => {
10 describe("makeCookie", () => {
11 it("rejects cookie attribute delimiters in names, domains, and paths", () => {
12 assertFailure(
13 Cookies.makeCookie("a; Domain=evil.com; b", "token"),
14 Cookies.CookiesError.fromReason("InvalidCookieName")
15 )
16 assertFailure(
17 Cookies.makeCookie("session", "token", { domain: "legit.com; Domain=.parent.tld" }),
18 Cookies.CookiesError.fromReason("InvalidCookieDomain")
19 )
20 assertFailure(
21 Cookies.makeCookie("session", "token", { path: "/; HttpOnly" }),
22 Cookies.CookiesError.fromReason("InvalidCookiePath")
23 )
24 })
25
26 it("accepts RFC 6265 token names and legitimate domains and paths", () => {
27 for (const domain of ["sub.example.com", ".sub.example.com"]) {
28 const cookie = Result.getOrThrow(
29 Cookies.makeCookie("!#$%&'*+-.^_`|~", "token", {
30 domain,
31 path: "/some-path_with~chars/%20"
32 })
33 )
34
35 assert.strictEqual(
36 Cookies.serializeCookie(cookie),
37 `!#$%&'*+-.^_\`|~=token; Domain=${domain}; Path=/some-path_with~chars/%20`
38 )
39 }
40 })
41 })
42
43 describe("fromSetCookie", () => {
44 it("ignores invalid cookie names", () => {
45 const cookies = Cookies.fromSetCookie([
46 "bad name=value",
47 "session=abc"
48 ])
49
50 assertNone(Cookies.get(cookies, "bad name"))
51 assertSome(Cookies.getValue(cookies, "session"), "abc")
52 })
53 })
54
55 describe("toSetCookieHeaders", () => {
56 const invalidCookie = {
57 name: "session",
58 value: "token",
59 valueEncoded: "token",
60 options: { domain: "legit.com; Domain=.evil.com" }
61 } as unknown as Cookies.Cookie
62
63 it("rejects invalid cookies supplied through fromIterable", () => {
64 const cookies = Cookies.fromIterable([invalidCookie])
65
66 assert.throws(() => Cookies.toSetCookieHeaders(cookies), /InvalidCookieDomain/)
67 })
68
69 it("rejects invalid cookies supplied through setCookie", () => {
70 const cookies = Cookies.setCookie(Cookies.empty, invalidCookie)
71
72 assert.throws(() => Cookies.toSetCookieHeaders(cookies), /InvalidCookieDomain/)
73 })
74 })
75
76 it("expireCookie returns a Result with an expired Set-Cookie value", () => {
77 assertSuccess(
78 Cookies.expireCookie(Cookies.empty, "session", { path: "/", secure: true }),
79 Cookies.fromReadonlyRecord({
80 session: Cookies.makeCookieUnsafe("session", "", {
81 path: "/",
82 secure: true,
83 maxAge: 0,
84 expires: new Date(0)
85 })
86 })
87 )
88 })
89
90 it("expireCookieUnsafe adds an expired Set-Cookie value", () => {
91 deepStrictEqual(
92 Cookies.expireCookieUnsafe(Cookies.empty, "session", { path: "/", secure: true }),
93 Cookies.fromReadonlyRecord({
94 session: Cookies.makeCookieUnsafe("session", "", {
95 path: "/",
96 secure: true,
97 maxAge: 0,
98 expires: new Date(0)
99 })
100 })
101 )
102 })
103
104 describe("Schema.Cookies", () => {
105 it("serializerIso annotation", () => {
106 const _sessionId = Schema.toIso(Schema.Cookies).at("sessionId")
107 const cookies = Cookies.fromSetCookie([
108 "sessionId=abc123; Path=/; HttpOnly; Secure",
109 "theme=dark; Path=/; Max-Age=3600",
110 "language=en; Domain=.example.com; SameSite=Lax"
111 ])
112 assertSuccess(
113 _sessionId.getResult(cookies),
114 Cookies.makeCookieUnsafe("sessionId", "abc123", { path: "/", httpOnly: true, secure: true })
115 )
116 })
117
118 it("toCodecJson", async () => {
119 const schema = Schema.Cookies
120 const asserts = new TestSchema.Asserts(Schema.toCodecJson(Schema.toType(schema)))
121
122 const encoding = asserts.encoding()
123
124 await encoding.succeed(
125 Cookies.fromSetCookie([
126 "sessionId=abc123; Path=/; HttpOnly; Secure",
127 "theme=dark; Path=/; Max-Age=3600",
128 "language=en; Domain=.example.com; SameSite=Lax"
129 ]),
130 [
131 "sessionId=abc123; Path=/; HttpOnly; Secure",
132 "theme=dark; Max-Age=3600; Path=/",
133 "language=en; Domain=example.com; SameSite=Lax"
134 ]
135 )
136 })
137 })
138
139 it("RecordFromCookies converts cookies to decoded string values", () => {
140 const cookies = Cookies.fromSetCookie(["session=abc", "theme=dark"])
141
142 deepStrictEqual(Schema.decodeSync(Schema.RecordFromCookies)(cookies), {
143 session: "abc",
144 theme: "dark"
145 })
146 deepStrictEqual(
147 Cookies.toSetCookieHeaders(
148 Schema.encodeSync(Schema.RecordFromCookies)({ session: "abc", theme: "dark" })
149 ),
150 ["session=abc", "theme=dark"]
151 )
152 })
153
154 it("get and getValue return Option", () => {
155 const cookies = Cookies.fromSetCookie("session=abc; Path=/")
156 assert.isTrue(Option.isSome(Cookies.get(cookies, "session")))
157 assertSome(Cookies.getValue(cookies, "session"), "abc")
158 assertNone(Cookies.get(cookies, "missing"))
159 assertNone(Cookies.getValue(cookies, "missing"))
160 assertNone(Cookies.get(Cookies.empty, "constructor"))
161 })
162
163 it("identifies cookies errors", () => {
164 assert.isTrue(Cookies.isCookiesError(Cookies.CookiesError.fromReason("InvalidCookieName")))
165 assert.isFalse(Cookies.isCookiesError({ _tag: "CookiesError" }))
166 assert.isFalse(Cookies.isCookiesError(null))
167 })
168})