| 1 | import { assert, describe, it } from "@effect/vitest" |
| 2 | import { assertNone, assertSome, deepStrictEqual } from "@effect/vitest/utils" |
| 3 | import { Result, Schema } from "effect" |
| 4 | import { Cookies } from "effect/http" |
| 5 | import * as Option from "effect/Option" |
| 6 | import { TestSchema } from "effect/testing" |
| 7 | import { assertFailure, assertSuccess } from "../utils/assert.ts" |
| 8 | |
| 9 | describe("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 | }) |