diff --git a/src/components/modals/budget-modal.tsx b/src/components/modals/budget-modal.tsx index 80c5014..b319566 100644 --- a/src/components/modals/budget-modal.tsx +++ b/src/components/modals/budget-modal.tsx @@ -16,7 +16,7 @@ import { useUserStore } from "@/stores/user-store" import { isSphinx, hasWebLN, payInvoice, payL402, topUpLsat, fetchTransactionHistory, pollPaymentStatus, fetchBuyLsatChallenge, savePendingLsat, getPendingLsat, clearPendingLsat, topUpStatus, withdraw, TransactionRow, PendingLsatChallenge } from "@/lib/sphinx" import { getActionDisplayLabel, getActionBadgeColor, isViewGrantRow } from "@/lib/transaction-display" import { isMocksEnabled, MOCK_TRANSACTIONS } from "@/lib/mock-data" -import { cookieStorage } from "@/lib/cookie-storage" +import { cookieStorage, AUTH_COOKIE_DAYS } from "@/lib/cookie-storage" import { api } from "@/lib/api" import { decodeInvoiceExpiry, decodeInvoiceAmountSats } from "@/lib/invoice-utils" import { formatCountdown } from "@/lib/format-countdown" @@ -228,6 +228,7 @@ export function BudgetModal() { identifier: pendingChallenge.id, preimage: "", }), + AUTH_COOKIE_DAYS ) clearPendingLsat() setPendingChallenge(null) @@ -292,7 +293,7 @@ export function BudgetModal() { await api.get<{ balance: number }>("/balance", { Authorization: `LSAT ${parsed.macaroon}:`, }) - cookieStorage.setItem("l402", JSON.stringify(parsed)) + cookieStorage.setItem("l402", JSON.stringify(parsed), AUTH_COOKIE_DAYS) await refreshBalance() setStep("success") } catch { @@ -392,6 +393,7 @@ export function BudgetModal() { identifier: challenge.id, preimage: "", }), + AUTH_COOKIE_DAYS ) clearPendingLsat() setPendingChallenge(null) diff --git a/src/lib/__tests__/cookie-storage.test.ts b/src/lib/__tests__/cookie-storage.test.ts new file mode 100644 index 0000000..5a242f9 --- /dev/null +++ b/src/lib/__tests__/cookie-storage.test.ts @@ -0,0 +1,95 @@ +import { describe, it, expect, beforeEach } from "vitest" +import { cookieStorage, AUTH_COOKIE_DAYS } from "@/lib/cookie-storage" + +describe("cookieStorage", () => { + beforeEach(() => { + // Clear all cookies between tests + document.cookie.split(";").forEach((c) => { + const key = c.trim().split("=")[0] + document.cookie = `${key}=; Max-Age=0; Path=/` + }) + }) + + it("exports AUTH_COOKIE_DAYS as 30", () => { + expect(AUTH_COOKIE_DAYS).toBe(30) + }) + + describe("setItem without days (session cookie)", () => { + it("stores the value and does not include Max-Age", () => { + cookieStorage.setItem("test_key", "test_value") + expect(cookieStorage.getItem("test_key")).toBe("test_value") + // document.cookie strips attributes — only the key=value pair is readable + // Verify Max-Age is NOT in the raw string when we can inspect it + // (jsdom exposes the full string via document.cookie which only shows key=value) + expect(document.cookie).toContain("test_key=") + }) + }) + + describe("setItem with days (persistent cookie)", () => { + it("stores the value and includes Max-Age when days is supplied", () => { + // Intercept document.cookie setter to capture the full string + let capturedCookieString = "" + const descriptor = Object.getOwnPropertyDescriptor(Document.prototype, "cookie")! + const originalSet = descriptor.set! + + Object.defineProperty(document, "cookie", { + set(value: string) { + capturedCookieString = value + originalSet.call(document, value) + }, + get: descriptor.get, + configurable: true, + }) + + cookieStorage.setItem("persistent_key", "persistent_value", AUTH_COOKIE_DAYS) + + // Restore original descriptor + Object.defineProperty(document, "cookie", descriptor) + + expect(capturedCookieString).toContain("Max-Age=") + expect(capturedCookieString).toContain(`${AUTH_COOKIE_DAYS * 24 * 60 * 60}`) + expect(cookieStorage.getItem("persistent_key")).toBe("persistent_value") + }) + }) + + describe("setItem without days (session cookie — no Max-Age)", () => { + it("does NOT include Max-Age in the cookie string", () => { + let capturedCookieString = "" + const descriptor = Object.getOwnPropertyDescriptor(Document.prototype, "cookie")! + const originalSet = descriptor.set! + + Object.defineProperty(document, "cookie", { + set(value: string) { + capturedCookieString = value + originalSet.call(document, value) + }, + get: descriptor.get, + configurable: true, + }) + + cookieStorage.setItem("session_key", "session_value") + + Object.defineProperty(document, "cookie", descriptor) + + expect(capturedCookieString).not.toContain("Max-Age=") + expect(cookieStorage.getItem("session_key")).toBe("session_value") + }) + }) + + it("getItem returns null for missing keys", () => { + expect(cookieStorage.getItem("nonexistent")).toBeNull() + }) + + it("removeItem deletes a stored cookie", () => { + cookieStorage.setItem("to_remove", "bye") + expect(cookieStorage.getItem("to_remove")).toBe("bye") + cookieStorage.removeItem("to_remove") + expect(cookieStorage.getItem("to_remove")).toBeNull() + }) + + it("handles special characters in values", () => { + const value = JSON.stringify({ macaroon: "abc123", preimage: "xyz==", identifier: "id/1" }) + cookieStorage.setItem("l402", value) + expect(cookieStorage.getItem("l402")).toBe(value) + }) +}) diff --git a/src/lib/cookie-storage.ts b/src/lib/cookie-storage.ts index ba13f1d..93a44fc 100644 --- a/src/lib/cookie-storage.ts +++ b/src/lib/cookie-storage.ts @@ -5,6 +5,8 @@ * Cookies are written with Secure; SameSite=Strict; Path=/ to reduce XSS * exposure. All methods are no-ops during SSR (typeof window === "undefined"). */ +export const AUTH_COOKIE_DAYS = 30 + export const cookieStorage = { getItem(key: string): string | null { if (typeof window === "undefined") return null diff --git a/src/lib/sphinx/bridge.ts b/src/lib/sphinx/bridge.ts index 368a58d..cb101b5 100644 --- a/src/lib/sphinx/bridge.ts +++ b/src/lib/sphinx/bridge.ts @@ -1,6 +1,6 @@ import type { SignedMessage } from "./types" import { isSphinx } from "./detect" -import { cookieStorage } from "@/lib/cookie-storage" +import { cookieStorage, AUTH_COOKIE_DAYS } from "@/lib/cookie-storage" // sphinx-bridge communicates via postMessage with the Sphinx webview host // eslint-disable-next-line @typescript-eslint/no-require-imports @@ -38,7 +38,7 @@ export async function getSignedMessage(): Promise { const result = await webln.signMessage(message) if (result?.signature) { const signed = { message, signature: result.signature } - cookieStorage.setItem("signature", JSON.stringify(signed)) + cookieStorage.setItem("signature", JSON.stringify(signed), AUTH_COOKIE_DAYS) return signed } } catch (error) { @@ -63,7 +63,7 @@ export async function getSignedMessage(): Promise { ) const result = await sphinx.signMessage(message) const signed = { message, signature: result.signature } - cookieStorage.setItem("signature", JSON.stringify(signed)) + cookieStorage.setItem("signature", JSON.stringify(signed), AUTH_COOKIE_DAYS) return signed } catch (error) { console.error("Failed to sign message:", error) @@ -100,7 +100,8 @@ export async function getL402(): Promise { macaroon: token.macaroon, identifier: token.identifier, preimage: token.preimage, - }) + }), + AUTH_COOKIE_DAYS ) return `LSAT ${token.macaroon}:${token.preimage}` } diff --git a/src/lib/sphinx/payment.ts b/src/lib/sphinx/payment.ts index e47e0bc..07cd7d0 100644 --- a/src/lib/sphinx/payment.ts +++ b/src/lib/sphinx/payment.ts @@ -1,7 +1,7 @@ import { Lsat } from "lsat-js" import { isSphinx } from "./detect" import { api } from "../api" -import { cookieStorage } from "@/lib/cookie-storage" +import { cookieStorage, AUTH_COOKIE_DAYS } from "@/lib/cookie-storage" import { isMocksEnabled, MOCK_TRANSACTIONS } from "@/lib/mock-data" import { decodeInvoiceAmountSats } from "@/lib/invoice-utils" @@ -76,7 +76,8 @@ async function payViaSphinx( macaroon: lsat.baseMacaroon, identifier: lsat.id, preimage: stored.preimage, - }) + }), + AUTH_COOKIE_DAYS ) setBudget(budgetAmount) return @@ -115,7 +116,8 @@ async function payViaWebLN( macaroon: lsat.baseMacaroon, identifier: lsat.id, preimage: payment.preimage, - }) + }), + AUTH_COOKIE_DAYS ) } setBudget(budgetAmount)