Folo/apps/cli/src/browser-login.test.ts

200 lines
6.4 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from "vitest"
import { DEFAULT_VALUES } from "../../../packages/internal/shared/src/env.common"
import { resolveBrowserLoginToken, resolveCLILoginUrl } from "./browser-login"
import { CLIError } from "./output"
afterEach(() => {
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
describe("browser login helpers", () => {
it("maps production API URL using env.common", () => {
const url = resolveCLILoginUrl(DEFAULT_VALUES.PROD.API_URL, "http://127.0.0.1:12345/callback")
const parsed = new URL(url)
expect(parsed.origin).toBe(new URL(DEFAULT_VALUES.PROD.WEB_URL).origin)
expect(parsed.pathname).toBe("/login")
expect(parsed.searchParams.get("cli_callback")).toBe("http://127.0.0.1:12345/callback")
})
it("maps dev API URL using env.common", () => {
const url = resolveCLILoginUrl(DEFAULT_VALUES.DEV.API_URL, "http://127.0.0.1:12345/callback")
const parsed = new URL(url)
expect(parsed.origin).toBe(new URL(DEFAULT_VALUES.DEV.WEB_URL).origin)
expect(parsed.pathname).toBe("/login")
expect(parsed.searchParams.get("cli_callback")).toBe("http://127.0.0.1:12345/callback")
})
it("maps local API URL using env.common", () => {
const url = resolveCLILoginUrl(DEFAULT_VALUES.LOCAL.API_URL, "http://127.0.0.1:12345/callback")
const parsed = new URL(url)
expect(parsed.origin).toBe(new URL(DEFAULT_VALUES.LOCAL.WEB_URL).origin)
expect(parsed.pathname).toBe("/login")
expect(parsed.searchParams.get("cli_callback")).toBe("http://127.0.0.1:12345/callback")
})
it("falls back to API origin when no mapping exists", () => {
const url = resolveCLILoginUrl("https://api.follow.is", "http://localhost:3456/callback")
const parsed = new URL(url)
expect(parsed.origin).toBe("https://api.follow.is")
expect(parsed.pathname).toBe("/login")
expect(parsed.searchParams.get("cli_callback")).toBe("http://localhost:3456/callback")
})
it("throws for invalid api url", () => {
expect(() => resolveCLILoginUrl("not-a-url", "http://127.0.0.1:3333/callback")).toThrowError(
/Invalid API URL/,
)
})
it("exchanges one-time token for a session token", async () => {
const fetchMock = vi.fn().mockResolvedValueOnce(
new Response(
JSON.stringify({
user: { id: "user-1" },
}),
{
status: 200,
headers: {
"content-type": "application/json",
"set-cookie":
"__Secure-better-auth.session_token=session-token; Path=/; HttpOnly; Secure; SameSite=None",
},
},
),
)
vi.stubGlobal("fetch", fetchMock)
const token = await resolveBrowserLoginToken(DEFAULT_VALUES.PROD.API_URL, "one-time-token")
expect(token).toBe("session-token")
expect(fetchMock).toHaveBeenCalledTimes(1)
expect(fetchMock).toHaveBeenCalledWith(
"https://api.folo.is/better-auth/one-time-token/apply",
expect.objectContaining({
method: "POST",
body: JSON.stringify({ token: "one-time-token" }),
}),
)
})
it("falls back to verify when apply endpoint is unavailable", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(
new Response(null, {
status: 404,
}),
)
.mockResolvedValueOnce(
new Response(
JSON.stringify({
session: { token: "session-token" },
user: { id: "user-1" },
}),
{
status: 200,
headers: { "content-type": "application/json" },
},
),
)
vi.stubGlobal("fetch", fetchMock)
const token = await resolveBrowserLoginToken(DEFAULT_VALUES.PROD.API_URL, "one-time-token")
expect(token).toBe("session-token")
expect(fetchMock).toHaveBeenNthCalledWith(
1,
"https://api.folo.is/better-auth/one-time-token/apply",
expect.objectContaining({
method: "POST",
body: JSON.stringify({ token: "one-time-token" }),
}),
)
expect(fetchMock).toHaveBeenNthCalledWith(
2,
"https://api.folo.is/better-auth/one-time-token/verify",
expect.objectContaining({
method: "POST",
body: JSON.stringify({ token: "one-time-token" }),
}),
)
})
it("falls back when the callback already contains a session token", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(
new Response(JSON.stringify({ message: "Invalid token" }), {
status: 400,
headers: { "content-type": "application/json" },
}),
)
.mockResolvedValueOnce(
new Response(
JSON.stringify({
session: { id: "session-1" },
user: { id: "user-1" },
}),
{
status: 200,
headers: { "content-type": "application/json" },
},
),
)
vi.stubGlobal("fetch", fetchMock)
const token = await resolveBrowserLoginToken(DEFAULT_VALUES.PROD.API_URL, "session-token")
expect(token).toBe("session-token")
expect(fetchMock).toHaveBeenCalledTimes(2)
expect(fetchMock).toHaveBeenNthCalledWith(
2,
"https://api.folo.is/better-auth/get-session",
expect.objectContaining({
method: "GET",
headers: expect.objectContaining({
Authorization: "Bearer session-token",
Cookie:
"__Secure-better-auth.session_token=session-token; better-auth.session_token=session-token",
}),
}),
)
})
it("surfaces verification failures when neither token path works", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(
new Response(JSON.stringify({ message: "Token expired" }), {
status: 400,
headers: { "content-type": "application/json" },
}),
)
.mockResolvedValueOnce(
new Response(JSON.stringify({ message: "Unauthorized" }), {
status: 401,
headers: { "content-type": "application/json" },
}),
)
vi.stubGlobal("fetch", fetchMock)
await expect(
resolveBrowserLoginToken(DEFAULT_VALUES.PROD.API_URL, "expired-token"),
).rejects.toEqual(
new CLIError("UNAUTHORIZED", "Browser login token verification failed: Token expired"),
)
})
it("throws invalid argument for malformed api url", async () => {
await expect(resolveBrowserLoginToken("not-a-url", "token")).rejects.toEqual(
new CLIError("INVALID_ARGUMENT", "Invalid API URL: not-a-url"),
)
})
})