| 1 | /** |
| 2 | * Signing the CLI in through the browser (auth/cli-login.ts): the loopback flow with PKCE, the device |
| 3 | * code flow, single use, expiry, denial, the recent sign-in rule, and `diffinery logout` revoking only |
| 4 | * CLI tokens. Runs the real router over node:sqlite with every migration. |
| 5 | */ |
| 6 | import { createHash, randomBytes } from "node:crypto"; |
| 7 | import { describe, expect, it } from "vitest"; |
| 8 | import { app } from "../../src/app.js"; |
| 9 | import { web } from "../../src/web/routes.js"; |
| 10 | import type { Env } from "../../src/env.js"; |
| 11 | import { cleanClient, formatUserCode, normalizeUserCode, pkceChallenge } from "../../src/auth/cli-login.js"; |
| 12 | import { placeMismatch, preselectedScope } from "../../src/web/cli-login.js"; |
| 13 | import { bucketsFor } from "../../src/lib/rate-limit.js"; |
| 14 | import { testD1 } from "./stubs/d1.js"; |
| 15 | |
| 16 | app.route("/", web); |
| 17 | |
| 18 | const ORIGIN = "https://diffinery.example"; |
| 19 | const hash = (s: string) => createHash("sha256").update(s).digest("hex"); |
| 20 | const CSRF = "csrf-token-0123456789abcdef"; |
| 21 | |
| 22 | function setup() { |
| 23 | const DB = testD1(); |
| 24 | const at = new Date().toISOString(); |
| 25 | const old = new Date(Date.now() - 5 * 3600_000).toISOString(); |
| 26 | const later = new Date(Date.now() + 30 * 86400_000).toISOString(); |
| 27 | DB.sqlite.exec(`INSERT INTO accounts (id, login, kind, name, email, status, created_at) VALUES ('u-ana', 'ana', 'user', 'Ana', 'ana@example.com', 'active', '${at}')`); |
| 28 | DB.sqlite.exec(`INSERT INTO sessions (id, account_id, method, created_at, last_seen_at, expires_at) VALUES ('${hash("ana-fresh-session-0000000000001")}', 'u-ana', 'passkey', '${at}', '${at}', '${later}')`); |
| 29 | DB.sqlite.exec(`INSERT INTO sessions (id, account_id, method, created_at, last_seen_at, expires_at) VALUES ('${hash("ana-stale-session-0000000000002")}', 'u-ana', 'passkey', '${old}', '${at}', '${later}')`); |
| 30 | DB.sqlite.exec(`INSERT INTO tokens (id, account_id, name, token_hash, prefix, scopes, expires_at, created_at) VALUES ('t-ci', 'u-ana', 'ci', '${hash("dfy_ana_ci")}', 'dfy_ana_ci', '["write"]', '${later}', '${at}')`); |
| 31 | const env = { PUBLIC_URL: ORIGIN, DB, BOOTSTRAP_ADMINS: "" } as unknown as Env; |
| 32 | const waits: Promise<unknown>[] = []; |
| 33 | const ctx = { waitUntil: (p: Promise<unknown>) => void waits.push(p), passThroughOnException() {}, props: {} } as unknown as ExecutionContext; |
| 34 | // `cf` stands in for the edge's request.cf, which a Worker gets and a client can't set. |
| 35 | const fetch = async (path: string, { cf, ...init }: RequestInit & { cf?: Record<string, string> } = {}) => { |
| 36 | const req = new Request(`${ORIGIN}${path}`, { redirect: "manual", ...init }); |
| 37 | if (cf) Object.defineProperty(req, "cf", { value: cf }); |
| 38 | const res = await app.fetch(req, env, ctx); |
| 39 | await Promise.allSettled(waits); |
| 40 | return res; |
| 41 | }; |
| 42 | const browser = (session = "ana-fresh-session-0000000000001") => ({ |
| 43 | get: (path: string) => fetch(path, { headers: { cookie: `__Host-dfy_session=${session}; dfy_csrf=${CSRF}` } }), |
| 44 | post: (path: string, form: Record<string, string>) => { |
| 45 | const body = new URLSearchParams({ _csrf: CSRF, ...form }).toString(); |
| 46 | return fetch(path, { method: "POST", headers: { cookie: `__Host-dfy_session=${session}; dfy_csrf=${CSRF}`, "content-type": "application/x-www-form-urlencoded", "content-length": String(Buffer.byteLength(body)), origin: ORIGIN, "sec-fetch-site": "same-origin" }, body }); |
| 47 | }, |
| 48 | }); |
| 49 | const cli = (path: string, body: unknown, method = "POST", token?: string) => fetch(path, { method, headers: { "content-type": "application/json", ...(token ? { authorization: `Bearer ${token}` } : {}) }, body: method === "DELETE" ? undefined : JSON.stringify(body) }); |
| 50 | const tokens = () => DB.sqlite.prepare("SELECT id, name, scopes, client, revoked_at FROM tokens WHERE account_id = 'u-ana' ORDER BY created_at").all() as Array<{ id: string; name: string; scopes: string; client: string | null; revoked_at: string | null }>; |
| 51 | return { DB, fetch, browser, cli, tokens }; |
| 52 | } |
| 53 | |
| 54 | /** What the CLI does before opening the browser. */ |
| 55 | async function pkce() { |
| 56 | const verifier = randomBytes(32).toString("base64url"); |
| 57 | const state = randomBytes(16).toString("base64url"); |
| 58 | return { verifier, state, challenge: await pkceChallenge(verifier) }; |
| 59 | } |
| 60 | |
| 61 | const authorizeUrl = (p: { state: string; challenge: string }, extra: Record<string, string> = {}) => |
| 62 | `/login/cli?${new URLSearchParams({ port: "53123", state: p.state, code_challenge: p.challenge, code_challenge_method: "S256", scope: "write", host: "ana-laptop", os: "darwin-arm64", version: "0.1.0", ...extra })}`; |
| 63 | |
| 64 | describe("CLI sign-in in the browser", () => { |
| 65 | it("shows who is asking, and sends the approval to the CLI's callback on 127.0.0.1 only", async () => { |
| 66 | const t = setup(); |
| 67 | const p = await pkce(); |
| 68 | const page = await t.browser().get(authorizeUrl(p)); |
| 69 | expect(page.status).toBe(200); |
| 70 | const html = await page.text(); |
| 71 | expect(html).toContain("Authorize the Diffinery CLI"); |
| 72 | expect(html).toContain("ana-laptop"); |
| 73 | expect(html).toContain("darwin-arm64"); |
| 74 | // The page's forms may lead to the CLI's port, nowhere else. |
| 75 | expect(page.headers.get("content-security-policy")).toContain("form-action 'self' http://127.0.0.1:53123;"); |
| 76 | expect(page.headers.get("x-frame-options")).toBe("DENY"); |
| 77 | |
| 78 | const res = await t.browser().post("/login/cli", { port: "53123", state: p.state, code_challenge: p.challenge, host: "ana-laptop", os: "darwin-arm64", version: "0.1.0", scope: "admin", decision: "approve" }); |
| 79 | expect(res.status).toBe(303); |
| 80 | const to = new URL(res.headers.get("location")!); |
| 81 | expect(to.origin).toBe("http://127.0.0.1:53123"); |
| 82 | expect(to.pathname).toBe("/callback"); |
| 83 | expect(to.searchParams.get("state")).toBe(p.state); |
| 84 | const code = to.searchParams.get("code")!; |
| 85 | expect(code.length).toBeGreaterThan(40); |
| 86 | // Nothing is minted until the CLI trades the code. |
| 87 | expect(t.tokens().filter((k) => k.client === "cli")).toHaveLength(0); |
| 88 | |
| 89 | // The code alone is useless: a wrong verifier burns it. |
| 90 | const wrong = await t.cli("/-/cli/token", { grant_type: "authorization_code", code, code_verifier: randomBytes(32).toString("base64url") }); |
| 91 | expect(wrong.status).toBe(400); |
| 92 | expect((await wrong.json()) as unknown).toMatchObject({ error: "invalid_grant" }); |
| 93 | const after = await t.cli("/-/cli/token", { grant_type: "authorization_code", code, code_verifier: p.verifier }); |
| 94 | expect(((await after.json()) as { error: string }).error).toBe("invalid_grant"); |
| 95 | expect(t.tokens().filter((k) => k.client === "cli")).toHaveLength(0); |
| 96 | }); |
| 97 | |
| 98 | it("trades the code and verifier for a CLI token once, with the scope the person chose", async () => { |
| 99 | const t = setup(); |
| 100 | const p = await pkce(); |
| 101 | const res = await t.browser().post("/login/cli", { port: "53123", state: p.state, code_challenge: p.challenge, host: "ana-laptop", os: "linux-x64", version: "0.1.0", scope: "read", decision: "approve" }); |
| 102 | const code = new URL(res.headers.get("location")!).searchParams.get("code")!; |
| 103 | const ok = await t.cli("/-/cli/token", { grant_type: "authorization_code", code, code_verifier: p.verifier }); |
| 104 | expect(ok.status).toBe(200); |
| 105 | expect(ok.headers.get("cache-control")).toBe("no-store"); |
| 106 | const body = (await ok.json()) as { access_token: string; scope: string; login: string; expires_at: string }; |
| 107 | // Letters and digits after the prefix, so `gh auth status` masks all of it. |
| 108 | expect(body.access_token).toMatch(/^dfy_[A-Za-z0-9]{40}$/); |
| 109 | expect(body).toMatchObject({ scope: "read", login: "ana" }); |
| 110 | expect(Date.parse(body.expires_at) - Date.now()).toBeGreaterThan(89 * 86400_000); |
| 111 | const cli = t.tokens().find((k) => k.client === "cli")!; |
| 112 | expect(cli).toMatchObject({ name: "Diffinery CLI on ana-laptop", scopes: '["read"]', revoked_at: null }); |
| 113 | // The token works, as the person, with its scope. |
| 114 | const me = await t.fetch("/api/v3/user", { headers: { authorization: `Bearer ${body.access_token}` } }); |
| 115 | expect(((await me.json()) as { login: string }).login).toBe("ana"); |
| 116 | // Single use. |
| 117 | const again = await t.cli("/-/cli/token", { grant_type: "authorization_code", code, code_verifier: p.verifier }); |
| 118 | expect(((await again.json()) as { error: string }).error).toBe("invalid_grant"); |
| 119 | // It shows up in Settings → Access tokens as a CLI sign-in. |
| 120 | const list = await (await t.browser().get("/settings/tokens")).text(); |
| 121 | expect(list).toContain("Diffinery CLI on ana-laptop"); |
| 122 | expect(list).toContain("CLI sign-in"); |
| 123 | // The sign-in is audited. |
| 124 | const audit = t.DB.sqlite.prepare("SELECT detail FROM audit_log WHERE action = 'token.create'").all() as Array<{ detail: string }>; |
| 125 | expect(audit.map((a) => a.detail).join()).toContain("cli browser sign-in"); |
| 126 | }); |
| 127 | |
| 128 | it("Cancel tells the CLI it was denied and creates nothing", async () => { |
| 129 | const t = setup(); |
| 130 | const p = await pkce(); |
| 131 | const res = await t.browser().post("/login/cli", { port: "53123", state: p.state, code_challenge: p.challenge, host: "h", decision: "deny" }); |
| 132 | const to = new URL(res.headers.get("location")!); |
| 133 | expect(to.origin).toBe("http://127.0.0.1:53123"); |
| 134 | expect(Object.fromEntries(to.searchParams)).toEqual({ error: "access_denied", state: p.state }); |
| 135 | expect(t.DB.sqlite.prepare("SELECT count(*) AS n FROM cli_logins").get()).toEqual({ n: 0 }); |
| 136 | }); |
| 137 | |
| 138 | it("needs a recent passkey sign-in, and sends signed-out people to sign in first", async () => { |
| 139 | const t = setup(); |
| 140 | const p = await pkce(); |
| 141 | const stale = await t.browser("ana-stale-session-0000000000002").get(authorizeUrl(p)); |
| 142 | expect(stale.status).toBe(303); |
| 143 | const confirm = new URL(stale.headers.get("location")!, ORIGIN); |
| 144 | expect(confirm.pathname).toBe("/login"); |
| 145 | expect(confirm.searchParams.get("confirm")).toBe("1"); |
| 146 | expect(confirm.searchParams.get("next")).toBe(authorizeUrl(p)); |
| 147 | const post = await t.browser("ana-stale-session-0000000000002").post("/login/cli", { port: "53123", state: p.state, code_challenge: p.challenge, decision: "approve" }); |
| 148 | expect(new URL(post.headers.get("location")!, ORIGIN).pathname).toBe("/login"); |
| 149 | expect(t.DB.sqlite.prepare("SELECT count(*) AS n FROM cli_logins").get()).toEqual({ n: 0 }); |
| 150 | |
| 151 | const anon = await t.fetch(authorizeUrl(p)); |
| 152 | expect(anon.status).toBe(302); |
| 153 | expect(new URL(anon.headers.get("location")!, ORIGIN).searchParams.get("next")).toBe(authorizeUrl(p)); |
| 154 | }); |
| 155 | |
| 156 | it("refuses links that could send the code anywhere but a local port", async () => { |
| 157 | const t = setup(); |
| 158 | const p = await pkce(); |
| 159 | for (const extra of [{ port: "80" }, { port: "70000" }, { port: "53123@evil.example" }, { code_challenge: "short" }, { code_challenge_method: "plain" }, { state: "" }]) { |
| 160 | const res = await t.browser().get(authorizeUrl(p, extra)); |
| 161 | expect(res.status, JSON.stringify(extra)).toBe(400); |
| 162 | expect(await res.text()).toContain("This sign-in link is incomplete"); |
| 163 | } |
| 164 | const res = await t.browser().post("/login/cli", { port: "22", state: p.state, code_challenge: p.challenge, decision: "approve" }); |
| 165 | expect(res.status).toBe(400); |
| 166 | }); |
| 167 | |
| 168 | it("a token can't approve: the form needs a browser", async () => { |
| 169 | const t = setup(); |
| 170 | const p = await pkce(); |
| 171 | const body = new URLSearchParams({ port: "53123", state: p.state, code_challenge: p.challenge, decision: "approve" }).toString(); |
| 172 | const res = await t.fetch("/login/cli", { method: "POST", headers: { authorization: "Bearer dfy_ana_ci", "content-type": "application/x-www-form-urlencoded", "content-length": String(body.length) }, body }); |
| 173 | expect(res.status).toBe(403); |
| 174 | }); |
| 175 | }); |
| 176 | |
| 177 | describe("CLI sign-in with a device code", () => { |
| 178 | const start = async (t: ReturnType<typeof setup>, scope = "write") => { |
| 179 | const r = await t.cli("/-/cli/device", { client: { host: "build-box", os: "linux-x64", version: "0.1.0" }, scope }); |
| 180 | expect(r.status).toBe(200); |
| 181 | return (await r.json()) as { device_code: string; user_code: string; verification_uri: string; expires_in: number; interval: number }; |
| 182 | }; |
| 183 | const poll = async (t: ReturnType<typeof setup>, device_code: string) => (await t.cli("/-/cli/token", { grant_type: "urn:ietf:params:oauth:grant-type:device_code", device_code })).json() as Promise<Record<string, string>>; |
| 184 | |
| 185 | it("waits for the person, then hands the CLI its token once", async () => { |
| 186 | const t = setup(); |
| 187 | const d = await start(t); |
| 188 | expect(d.user_code).toMatch(/^[BCDFGHJKLMNPQRSTVWXZ]{4}-[BCDFGHJKLMNPQRSTVWXZ]{4}$/); |
| 189 | expect(d.verification_uri).toBe(`${ORIGIN}/login/device`); |
| 190 | expect(d).not.toHaveProperty("verification_uri_complete"); |
| 191 | expect(d).toMatchObject({ expires_in: 600, interval: 5 }); |
| 192 | expect((await poll(t, d.device_code)).error).toBe("authorization_pending"); |
| 193 | |
| 194 | // Typed in any case, with or without the dash. |
| 195 | const page = await t.browser().post("/login/device", { code: d.user_code.replace("-", "").toLowerCase() }); |
| 196 | expect(page.status).toBe(200); |
| 197 | const html = await page.text(); |
| 198 | expect(html).toContain("build-box"); |
| 199 | expect(html).toContain(d.user_code); |
| 200 | const id = /name="id" value="([^"]+)"/.exec(html)![1]!; |
| 201 | const done = await t.browser().post("/login/device/decide", { id, scope: "admin", decision: "approve" }); |
| 202 | expect(await done.text()).toMatch(/You('|')re signed in/); |
| 203 | |
| 204 | const got = await poll(t, d.device_code); |
| 205 | expect(got.access_token).toMatch(/^dfy_/); |
| 206 | expect(got).toMatchObject({ scope: "admin", login: "ana" }); |
| 207 | expect((await poll(t, d.device_code)).error).toBe("invalid_grant"); |
| 208 | // Approving again changes nothing. |
| 209 | expect(await (await t.browser().post("/login/device/decide", { id, decision: "approve" })).text()).toContain("already answered"); |
| 210 | }); |
| 211 | |
| 212 | it("tells the CLI when the person cancels, and an unknown code finds nothing", async () => { |
| 213 | const t = setup(); |
| 214 | const d = await start(t, "read"); |
| 215 | const html = await (await t.browser().post("/login/device", { code: d.user_code })).text(); |
| 216 | const id = /name="id" value="([^"]+)"/.exec(html)![1]!; |
| 217 | await t.browser().post("/login/device/decide", { id, decision: "deny" }); |
| 218 | expect((await poll(t, d.device_code)).error).toBe("access_denied"); |
| 219 | expect((await poll(t, "not-a-device-code")).error).toBe("invalid_grant"); |
| 220 | const unknown = await t.browser().post("/login/device", { code: "BCDF-GHJK" }); |
| 221 | expect(unknown.status).toBe(404); |
| 222 | expect(await unknown.text()).toMatch(/isn('|')t valid or has expired/); |
| 223 | }); |
| 224 | |
| 225 | it("expires", async () => { |
| 226 | const t = setup(); |
| 227 | const d = await start(t); |
| 228 | t.DB.sqlite.exec(`UPDATE cli_logins SET expires_at = '${new Date(Date.now() - 1000).toISOString()}'`); |
| 229 | expect((await poll(t, d.device_code)).error).toBe("expired_token"); |
| 230 | expect((await t.browser().post("/login/device", { code: d.user_code })).status).toBe(404); |
| 231 | }); |
| 232 | |
| 233 | it("refuses an unknown scope and counts starts against the sign-in rate limit", async () => { |
| 234 | const t = setup(); |
| 235 | expect((await t.cli("/-/cli/device", { scope: "root" })).status).toBe(422); |
| 236 | expect(bucketsFor("POST", "/-/cli/device", { type: "anonymous" }, "203.0.113.1")).toEqual([["RL_AUTH", "ip:203.0.113.1"]]); |
| 237 | expect(bucketsFor("POST", "/login/device", { type: "anonymous" }, "203.0.113.1")).toEqual([["RL_AUTH", "ip:203.0.113.1"]]); |
| 238 | }); |
| 239 | }); |
| 240 | |
| 241 | describe("diffinery logout", () => { |
| 242 | it("revokes a CLI token with itself, and leaves other tokens alone", async () => { |
| 243 | const t = setup(); |
| 244 | const p = await pkce(); |
| 245 | const res = await t.browser().post("/login/cli", { port: "53123", state: p.state, code_challenge: p.challenge, host: "h", decision: "approve" }); |
| 246 | const code = new URL(res.headers.get("location")!).searchParams.get("code")!; |
| 247 | const { access_token } = (await (await t.cli("/-/cli/token", { grant_type: "authorization_code", code, code_verifier: p.verifier })).json()) as { access_token: string }; |
| 248 | expect(await (await t.cli("/-/cli/token", null, "DELETE", "dfy_ana_ci")).json()).toEqual({ revoked: false }); |
| 249 | expect(t.tokens().find((k) => k.id === "t-ci")!.revoked_at).toBeNull(); |
| 250 | expect(await (await t.cli("/-/cli/token", null, "DELETE", access_token)).json()).toEqual({ revoked: true }); |
| 251 | expect((await t.fetch("/api/v3/user", { headers: { authorization: `Bearer ${access_token}` } })).status).toBe(401); |
| 252 | expect((await t.cli("/-/cli/token", null, "DELETE")).status).toBe(401); |
| 253 | }); |
| 254 | }); |
| 255 | |
| 256 | describe("guards against approving someone else's sign-in", () => { |
| 257 | it("never preselects admin, and says when the CLI asked for it", async () => { |
| 258 | const t = setup(); |
| 259 | const p = await pkce(); |
| 260 | const html = await (await t.browser().get(authorizeUrl(p, { scope: "admin" }))).text(); |
| 261 | expect(html).toContain(""value":"write""); |
| 262 | expect(html).not.toContain(""value":"admin","items"); |
| 263 | expect(html).toContain("The CLI asked for Admin"); |
| 264 | expect(preselectedScope("admin")).toBe("write"); |
| 265 | expect(preselectedScope("read")).toBe("read"); |
| 266 | const plain = await (await t.browser().get(authorizeUrl(p))).text(); |
| 267 | expect(plain).not.toContain("The CLI asked for Admin"); |
| 268 | }); |
| 269 | |
| 270 | it("warns when the device code came from another network or country", async () => { |
| 271 | const t = setup(); |
| 272 | const r = await t.fetch("/-/cli/device", { method: "POST", cf: { city: "Lagos", country: "NG" }, headers: { "content-type": "application/json", "cf-connecting-ip": "203.0.113.5" }, body: JSON.stringify({ client: { host: "far-away" } }) }); |
| 273 | const d = (await r.json()) as { user_code: string }; |
| 274 | const approve = (ip: string, country?: string, header?: string) => |
| 275 | t.fetch("/login/device", { |
| 276 | method: "POST", |
| 277 | cf: country ? { country } : undefined, |
| 278 | headers: { cookie: `__Host-dfy_session=ana-fresh-session-0000000000001; dfy_csrf=${CSRF}`, "content-type": "application/x-www-form-urlencoded", "content-length": String(new URLSearchParams({ _csrf: CSRF, code: d.user_code }).toString().length), origin: ORIGIN, "cf-connecting-ip": ip, ...(header ? { "cf-ipcountry": header } : {}) }, |
| 279 | body: new URLSearchParams({ _csrf: CSRF, code: d.user_code }).toString(), |
| 280 | }); |
| 281 | expect(await (await approve("198.51.100.7")).text()).toContain("from a different network (203.0.113.5)"); |
| 282 | expect(await (await approve("203.0.113.5")).text()).not.toContain("different network"); |
| 283 | // The country comes from the edge's request.cf only; a CF-IPCountry header the client sends is ignored. |
| 284 | expect(await (await approve("198.51.100.7", "TR")).text()).toContain("asked for from another country</b> (Lagos, NG)"); |
| 285 | expect(await (await approve("198.51.100.7", "NG")).text()).not.toContain("another country"); |
| 286 | expect(await (await approve("198.51.100.7", undefined, "TR")).text()).not.toContain("another country"); |
| 287 | expect(placeMismatch({ ip: "1.1.1.1", location: "Lagos, NG" }, { ip: "2.2.2.2", country: "TR" })).toBe("country"); |
| 288 | expect(placeMismatch({ ip: "1.1.1.1", location: "TR" }, { ip: "2.2.2.2", country: "TR" })).toBe("network"); |
| 289 | expect(placeMismatch({ ip: "1.1.1.1", location: "Istanbul, TR" }, { ip: "1.1.1.1", country: "TR" })).toBeNull(); |
| 290 | expect(placeMismatch({ ip: null, location: null }, { ip: "1.1.1.1" })).toBeNull(); |
| 291 | expect(placeMismatch({ ip: "1.1.1.1", location: "Istanbul, TR" }, { ip: "2.2.2.2", country: "TR" })).toBe("network"); |
| 292 | expect(placeMismatch({ ip: "1.1.1.1", location: "Istanbul" }, { ip: "1.1.1.1", country: "TR" })).toBeNull(); |
| 293 | }); |
| 294 | }); |
| 295 | |
| 296 | describe("codes and client details", () => { |
| 297 | it("normalizes typed codes", () => { |
| 298 | expect(normalizeUserCode("bcdf-ghjk")).toBe("BCDFGHJK"); |
| 299 | expect(normalizeUserCode(" BCDF GHJK ")).toBe("BCDFGHJK"); |
| 300 | expect(normalizeUserCode("BCDF-GHJA")).toBeNull(); |
| 301 | expect(normalizeUserCode("BCDF-GHJ")).toBeNull(); |
| 302 | expect(formatUserCode("BCDFGHJK")).toBe("BCDF-GHJK"); |
| 303 | }); |
| 304 | |
| 305 | it("keeps what the CLI says about itself short and plain", () => { |
| 306 | expect(cleanClient({ host: "<script>alert(1)</script>", os: 42, version: "0.1.0" })).toEqual({ host: "scriptalert(1)/script", os: "", version: "0.1.0" }); |
| 307 | expect(cleanClient({}).host).toBe("unknown host"); |
| 308 | expect(cleanClient({ host: "x".repeat(200) }).host).toHaveLength(64); |
| 309 | }); |
| 310 | }); |