Something went wrong. Try again.
CLI for FastMail via JMAP
Something went wrong. Try again.
11 kB · 346 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";import { chmod, mkdir, rm, stat } from "node:fs/promises";import { join } from "node:path";import * as configModule from "./config.ts";import { checkConfigPermissions, getApiToken, getConfigDir, getConfigPath, loadConfig, saveConfig, updateConfig, validateConfig,} from "./config.ts";
// Use a temp directory for testsconst TEST_CONFIG_DIR = join(import.meta.dir, "../.test-config");const TEST_CONFIG_PATH = join(TEST_CONFIG_DIR, "config.json");
describe("config", () => { // Mock the config paths for testing let getConfigDirSpy: 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 }); });
afterEach(async () => { // Clean up test directory await rm(TEST_CONFIG_DIR, { recursive: true, force: true });
// Restore spies getConfigDirSpy?.mockRestore(); getConfigPathSpy?.mockRestore();
// Clear env var delete process.env.FASTMAIL_API_TOKEN; });
describe("getConfigDir", () => { test("returns path under home directory", () => { const dir = getConfigDir(); expect(dir).toContain(".config/fastmail-cli"); }); });
describe("getConfigPath", () => { test("returns path to config.json", () => { const path = getConfigPath(); expect(path).toContain(".config/fastmail-cli/config.json"); }); });
describe("validateConfig", () => { test("returns default config for null input", () => { const config = validateConfig(null); expect(config).toEqual({ outputFormat: "table" }); });
test("returns default config for undefined input", () => { const config = validateConfig(undefined); expect(config).toEqual({ outputFormat: "table" }); });
test("returns default config for non-object input", () => { const config = validateConfig("not an object"); expect(config).toEqual({ outputFormat: "table" }); });
test("extracts valid apiToken", () => { const config = validateConfig({ apiToken: "test-token" }); expect(config.apiToken).toBe("test-token"); });
test("ignores non-string apiToken", () => { const config = validateConfig({ apiToken: 12345 }); expect(config.apiToken).toBeUndefined(); });
test("extracts valid defaultAccountId", () => { const config = validateConfig({ defaultAccountId: "account-123" }); expect(config.defaultAccountId).toBe("account-123"); });
test("extracts valid outputFormat json", () => { const config = validateConfig({ outputFormat: "json" }); expect(config.outputFormat).toBe("json"); });
test("extracts valid outputFormat table", () => { const config = validateConfig({ outputFormat: "table" }); expect(config.outputFormat).toBe("table"); });
test("ignores invalid outputFormat", () => { const config = validateConfig({ outputFormat: "csv" }); expect(config.outputFormat).toBe("table"); // default });
test("strips unknown fields", () => { const config = validateConfig({ apiToken: "token", unknownField: "value", anotherUnknown: 123, }); expect(config).toEqual({ apiToken: "token", outputFormat: "table", }); }); });
describe("loadConfig", () => { test("returns default config when file does not exist", async () => { // Mock to use test path getConfigPathSpy = spyOn(configModule, "getConfigPath").mockReturnValue( join(TEST_CONFIG_DIR, "nonexistent.json"), );
const config = await loadConfig(); expect(config).toEqual({ outputFormat: "table" }); });
test("loads config from file", async () => { // Write test config await Bun.write( TEST_CONFIG_PATH, JSON.stringify({ apiToken: "saved-token", outputFormat: "json" }), );
// Mock to use test path getConfigPathSpy = spyOn(configModule, "getConfigPath").mockReturnValue( TEST_CONFIG_PATH, );
const config = await loadConfig(); expect(config.apiToken).toBe("saved-token"); expect(config.outputFormat).toBe("json"); });
test("returns default config for invalid JSON", async () => { // Write invalid JSON await Bun.write(TEST_CONFIG_PATH, "not valid json {{{");
// Mock to use test path getConfigPathSpy = spyOn(configModule, "getConfigPath").mockReturnValue( TEST_CONFIG_PATH, );
// Suppress console.error for this test const consoleErrorSpy = spyOn(console, "error").mockImplementation( () => {}, );
const config = await loadConfig(); expect(config).toEqual({ outputFormat: "table" });
consoleErrorSpy.mockRestore(); }); });
describe("saveConfig", () => { test("creates config file with proper permissions", async () => { const testPath = join(TEST_CONFIG_DIR, "subdir", "config.json");
// Mock to use test path getConfigPathSpy = spyOn(configModule, "getConfigPath").mockReturnValue( testPath, );
await saveConfig({ apiToken: "new-token", outputFormat: "json" });
// Verify file was created const file = Bun.file(testPath); expect(await file.exists()).toBe(true);
// Verify content const content = await file.json(); expect(content.apiToken).toBe("new-token");
// Verify permissions (0600 = owner read/write only) const stats = await stat(testPath); expect(stats.mode & 0o777).toBe(0o600); });
test("overwrites existing config", async () => { // Write initial config await Bun.write( TEST_CONFIG_PATH, JSON.stringify({ apiToken: "old-token" }), );
// Mock to use test path getConfigPathSpy = spyOn(configModule, "getConfigPath").mockReturnValue( TEST_CONFIG_PATH, );
await saveConfig({ apiToken: "new-token", outputFormat: "table" });
const file = Bun.file(TEST_CONFIG_PATH); const content = await file.json(); expect(content.apiToken).toBe("new-token"); }); });
describe("updateConfig", () => { test("merges updates with existing config", async () => { // Write initial config await Bun.write( TEST_CONFIG_PATH, JSON.stringify({ apiToken: "existing-token", outputFormat: "table" }), );
// Mock to use test path getConfigPathSpy = spyOn(configModule, "getConfigPath").mockReturnValue( TEST_CONFIG_PATH, );
const updated = await updateConfig({ outputFormat: "json" });
expect(updated.apiToken).toBe("existing-token"); // preserved expect(updated.outputFormat).toBe("json"); // updated
// Verify persisted const file = Bun.file(TEST_CONFIG_PATH); const content = await file.json(); expect(content.apiToken).toBe("existing-token"); expect(content.outputFormat).toBe("json"); }); });
describe("getApiToken", () => { test("returns env var when set", async () => { process.env.FASTMAIL_API_TOKEN = "env-token";
// Mock to use test path (with no config file) getConfigPathSpy = spyOn(configModule, "getConfigPath").mockReturnValue( join(TEST_CONFIG_DIR, "nonexistent.json"), );
const token = await getApiToken(); expect(token).toBe("env-token"); });
test("returns config token when env not set", async () => { // Ensure env var is not set delete process.env.FASTMAIL_API_TOKEN;
// Write config with token await Bun.write( TEST_CONFIG_PATH, JSON.stringify({ apiToken: "config-token" }), );
// Mock to use test path getConfigPathSpy = spyOn(configModule, "getConfigPath").mockReturnValue( TEST_CONFIG_PATH, );
const token = await getApiToken(); expect(token).toBe("config-token"); });
test("env var takes precedence over config", async () => { process.env.FASTMAIL_API_TOKEN = "env-token";
// Write config with different token await Bun.write( TEST_CONFIG_PATH, JSON.stringify({ apiToken: "config-token" }), );
// Mock to use test path getConfigPathSpy = spyOn(configModule, "getConfigPath").mockReturnValue( TEST_CONFIG_PATH, );
const token = await getApiToken(); expect(token).toBe("env-token"); });
test("returns undefined when no token available", async () => { delete process.env.FASTMAIL_API_TOKEN;
// Mock to use test path (with no config file) getConfigPathSpy = spyOn(configModule, "getConfigPath").mockReturnValue( join(TEST_CONFIG_DIR, "nonexistent.json"), );
const token = await getApiToken(); expect(token).toBeUndefined(); }); });
describe("checkConfigPermissions", () => { test("returns true for file with 0600 permissions", async () => { // Write config and set permissions await Bun.write(TEST_CONFIG_PATH, JSON.stringify({ apiToken: "token" })); await chmod(TEST_CONFIG_PATH, 0o600);
// Mock to use test path getConfigPathSpy = spyOn(configModule, "getConfigPath").mockReturnValue( TEST_CONFIG_PATH, );
const result = await checkConfigPermissions(); expect(result).toBe(true); });
test("returns false and warns for world-readable file", async () => { // Write config and set unsafe permissions await Bun.write(TEST_CONFIG_PATH, JSON.stringify({ apiToken: "token" })); await chmod(TEST_CONFIG_PATH, 0o644);
// Mock to use test path getConfigPathSpy = spyOn(configModule, "getConfigPath").mockReturnValue( TEST_CONFIG_PATH, );
// Capture console.error const consoleErrorSpy = spyOn(console, "error").mockImplementation( () => {}, );
const result = await checkConfigPermissions();
expect(result).toBe(false); expect(consoleErrorSpy).toHaveBeenCalled();
consoleErrorSpy.mockRestore(); });
test("returns true when file does not exist", async () => { // Mock to use nonexistent path getConfigPathSpy = spyOn(configModule, "getConfigPath").mockReturnValue( join(TEST_CONFIG_DIR, "nonexistent.json"), );
const result = await checkConfigPermissions(); expect(result).toBe(true); }); });});