Something went wrong. Try again.
CLI for FastMail via JMAP
Something went wrong. Try again.
8.9 kB · 292 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";import { chmod, mkdir, rm } from "node:fs/promises";import { join } from "node:path";import { fetchSession, getAuthStatus, login, logout, validateToken,} from "./auth.ts";import * as configModule from "./config.ts";import { JMAP_CORE_CAPABILITY, JMAP_MAIL_CAPABILITY } from "./jmap/types.ts";
// Mock session response based on JMAP spec// See: https://jmap.io/spec-core.html#the-jmap-session-resourceconst mockSession = { username: "test@fastmail.com", apiUrl: "https://api.fastmail.com/jmap/api/", downloadUrl: "https://api.fastmail.com/jmap/download/{accountId}/{blobId}/{name}", uploadUrl: "https://api.fastmail.com/jmap/upload/{accountId}/", eventSourceUrl: "https://api.fastmail.com/jmap/eventsource/", state: "session-state-123", accounts: { "account-123": { name: "Test Account", isPersonal: true, isReadOnly: false, accountCapabilities: { [JMAP_CORE_CAPABILITY]: {}, [JMAP_MAIL_CAPABILITY]: {}, }, }, }, primaryAccounts: { [JMAP_CORE_CAPABILITY]: "account-123", [JMAP_MAIL_CAPABILITY]: "account-123", }, capabilities: { [JMAP_CORE_CAPABILITY]: {}, [JMAP_MAIL_CAPABILITY]: {}, },};
function createMockResponse(data: unknown, status = 200): Response { return new Response(JSON.stringify(data), { status, headers: { "Content-Type": "application/json" }, });}
// Use a temp directory for testsconst TEST_CONFIG_DIR = join(import.meta.dir, "../.test-config-auth");const TEST_CONFIG_PATH = join(TEST_CONFIG_DIR, "config.json");
describe("auth", () => { let fetchSpy: ReturnType<typeof spyOn>; let getConfigPathSpy: ReturnType<typeof spyOn>;
beforeEach(async () => { // Clean up test directory await rm(TEST_CONFIG_DIR, { recursive: true, force: true }); await mkdir(TEST_CONFIG_DIR, { recursive: true });
// Mock config path getConfigPathSpy = spyOn(configModule, "getConfigPath").mockReturnValue( TEST_CONFIG_PATH, ); });
afterEach(async () => { // Clean up fetchSpy?.mockRestore(); getConfigPathSpy?.mockRestore();
await rm(TEST_CONFIG_DIR, { recursive: true, force: true });
// Clear env var delete process.env.FASTMAIL_API_TOKEN; });
describe("fetchSession", () => { test("fetches and returns session for valid token", async () => { fetchSpy = spyOn(globalThis, "fetch").mockResolvedValue( createMockResponse(mockSession), );
const session = await fetchSession("valid-token");
expect(session.username).toBe("test@fastmail.com"); expect(session.apiUrl).toBe("https://api.fastmail.com/jmap/api/"); expect(fetchSpy).toHaveBeenCalledWith( "https://api.fastmail.com/jmap/session", expect.objectContaining({ headers: { Authorization: "Bearer valid-token", }, }), ); });
test("throws error for 401 unauthorized", async () => { fetchSpy = spyOn(globalThis, "fetch").mockResolvedValue( createMockResponse({ error: "Unauthorized" }, 401), );
expect(fetchSession("invalid-token")).rejects.toThrow( "Invalid API token", ); });
test("throws error for other failures", async () => { fetchSpy = spyOn(globalThis, "fetch").mockResolvedValue( createMockResponse({ error: "Server Error" }, 500), );
expect(fetchSession("token")).rejects.toThrow("Failed to authenticate"); }); });
describe("validateToken", () => { test("returns true for valid token", async () => { fetchSpy = spyOn(globalThis, "fetch").mockResolvedValue( createMockResponse(mockSession), );
const isValid = await validateToken("valid-token"); expect(isValid).toBe(true); });
test("returns false for invalid token", async () => { fetchSpy = spyOn(globalThis, "fetch").mockResolvedValue( createMockResponse({ error: "Unauthorized" }, 401), );
const isValid = await validateToken("invalid-token"); expect(isValid).toBe(false); }); });
describe("login", () => { test("stores token and returns success for valid token", async () => { fetchSpy = spyOn(globalThis, "fetch").mockResolvedValue( createMockResponse(mockSession), );
const result = await login("valid-token");
expect(result.success).toBe(true); expect(result.username).toBe("test@fastmail.com"); expect(result.primaryAccountId).toBe("account-123"); expect(result.accountName).toBe("Test Account");
// Verify token was saved const configFile = Bun.file(TEST_CONFIG_PATH); const savedConfig = await configFile.json(); expect(savedConfig.apiToken).toBe("valid-token"); expect(savedConfig.defaultAccountId).toBe("account-123"); });
test("returns error for invalid token", async () => { fetchSpy = spyOn(globalThis, "fetch").mockResolvedValue( createMockResponse({ error: "Unauthorized" }, 401), );
const result = await login("invalid-token");
expect(result.success).toBe(false); expect(result.error).toBe("Invalid API token");
// Verify no config was saved const configFile = Bun.file(TEST_CONFIG_PATH); expect(await configFile.exists()).toBe(false); }); });
describe("logout", () => { test("removes token from config", async () => { // Setup: save a config with token await Bun.write( TEST_CONFIG_PATH, JSON.stringify({ apiToken: "existing-token", defaultAccountId: "account-123", outputFormat: "table", }), ); await chmod(TEST_CONFIG_PATH, 0o600);
await logout();
// Verify token was removed const configFile = Bun.file(TEST_CONFIG_PATH); const savedConfig = await configFile.json(); expect(savedConfig.apiToken).toBeUndefined(); expect(savedConfig.defaultAccountId).toBeUndefined(); expect(savedConfig.outputFormat).toBe("table"); // preserved }); });
describe("getAuthStatus", () => { test("returns authenticated status for valid config token", async () => { fetchSpy = spyOn(globalThis, "fetch").mockResolvedValue( createMockResponse(mockSession), );
// Setup config with token await Bun.write( TEST_CONFIG_PATH, JSON.stringify({ apiToken: "config-token" }), ); await chmod(TEST_CONFIG_PATH, 0o600);
const status = await getAuthStatus();
expect(status.authenticated).toBe(true); expect(status.username).toBe("test@fastmail.com"); expect(status.primaryAccountId).toBe("account-123"); expect(status.accountName).toBe("Test Account"); expect(status.tokenSource).toBe("config"); });
test("returns authenticated status for valid env token", async () => { fetchSpy = spyOn(globalThis, "fetch").mockResolvedValue( createMockResponse(mockSession), );
process.env.FASTMAIL_API_TOKEN = "env-token";
const status = await getAuthStatus();
expect(status.authenticated).toBe(true); expect(status.tokenSource).toBe("env"); });
test("returns not authenticated when no token", async () => { delete process.env.FASTMAIL_API_TOKEN; // No config file
const status = await getAuthStatus();
expect(status.authenticated).toBe(false); expect(status.error).toBe("No API token configured"); });
test("returns not authenticated for invalid token", async () => { fetchSpy = spyOn(globalThis, "fetch").mockResolvedValue( createMockResponse({ error: "Unauthorized" }, 401), );
// Setup config with invalid token await Bun.write( TEST_CONFIG_PATH, JSON.stringify({ apiToken: "invalid-token" }), ); await chmod(TEST_CONFIG_PATH, 0o600);
const status = await getAuthStatus();
expect(status.authenticated).toBe(false); expect(status.tokenSource).toBe("config"); expect(status.error).toBe("Invalid API token"); });
test("env token takes precedence over config", async () => { fetchSpy = spyOn(globalThis, "fetch").mockResolvedValue( createMockResponse(mockSession), );
process.env.FASTMAIL_API_TOKEN = "env-token";
// Setup config with different token await Bun.write( TEST_CONFIG_PATH, JSON.stringify({ apiToken: "config-token" }), ); await chmod(TEST_CONFIG_PATH, 0o600);
const status = await getAuthStatus();
expect(status.tokenSource).toBe("env"); expect(fetchSpy).toHaveBeenCalledWith( expect.any(String), expect.objectContaining({ headers: { Authorization: "Bearer env-token", }, }), ); }); });});