diff --git a/skills/spotify/src/auth.ts b/skills/spotify/src/auth.ts new file mode 100644 index 0000000..a37e9a3 --- /dev/null +++ b/skills/spotify/src/auth.ts @@ -0,0 +1,230 @@ +import { createHash, randomBytes } from "node:crypto"; +import { createServer } from "node:http"; +import type { AddressInfo } from "node:net"; +import open from "open"; + +import { loadSpotifyConfig, type ResolveConfigOptions } from "./config.js"; +import { loadToken, saveToken } from "./token-store.js"; +import type { SpotifyConfig, SpotifyToken } from "./types.js"; + +export const SPOTIFY_SCOPES = [ + "playlist-modify-public", + "playlist-modify-private", + "playlist-read-private", + "playlist-read-collaborative" +]; + +type FetchLike = typeof fetch; + +export interface TokenEndpointResponse { + access_token: string; + refresh_token?: string; + expires_in: number; +} + +export interface AuthFlowOptions extends ResolveConfigOptions { + fetchImpl?: FetchLike; + openUrl?: (url: string) => Promise; + now?: () => number; +} + +export interface AuthStatus { + configFound: boolean; + tokenFound: boolean; + tokenExpired: boolean | null; + expiresAt: number | null; +} + +function base64Url(buffer: Buffer): string { + return buffer.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/u, ""); +} + +export function createCodeVerifier(bytes = randomBytes(64)): string { + return base64Url(bytes); +} + +export function createCodeChallenge(verifier: string): string { + return base64Url(createHash("sha256").update(verifier).digest()); +} + +export function createState(bytes = randomBytes(32)): string { + return base64Url(bytes); +} + +export function buildAuthorizeUrl(config: SpotifyConfig, verifier: string, state: string, scopes = SPOTIFY_SCOPES): string { + const params = new URLSearchParams({ + response_type: "code", + client_id: config.clientId, + scope: scopes.join(" "), + redirect_uri: config.redirectUri, + state, + code_challenge_method: "S256", + code_challenge: createCodeChallenge(verifier) + }); + return `https://accounts.spotify.com/authorize?${params.toString()}`; +} + +async function parseTokenResponse(response: Response, fallbackRefreshToken?: string, now = Date.now()): Promise { + if (!response.ok) { + throw new Error(`Spotify token request failed with status ${response.status}.`); + } + const body = await response.json() as Partial; + if (typeof body.access_token !== "string" || typeof body.expires_in !== "number") { + throw new Error("Spotify token response was missing required fields."); + } + if (!body.refresh_token && !fallbackRefreshToken) { + throw new Error("Spotify token response did not include a refresh token."); + } + return { + accessToken: body.access_token, + refreshToken: body.refresh_token ?? fallbackRefreshToken ?? "", + expiresAt: now + body.expires_in * 1000 + }; +} + +export async function exchangeCodeForToken( + config: SpotifyConfig, + code: string, + verifier: string, + fetchImpl: FetchLike = fetch, + now = Date.now() +): Promise { + const body = new URLSearchParams({ + grant_type: "authorization_code", + code, + redirect_uri: config.redirectUri, + client_id: config.clientId, + code_verifier: verifier + }); + const response = await fetchImpl("https://accounts.spotify.com/api/token", { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body + }); + return parseTokenResponse(response, undefined, now); +} + +export async function refreshAccessToken( + config: SpotifyConfig, + refreshToken: string, + fetchImpl: FetchLike = fetch, + now = Date.now() +): Promise { + const body = new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: refreshToken, + client_id: config.clientId + }); + const response = await fetchImpl("https://accounts.spotify.com/api/token", { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body + }); + return parseTokenResponse(response, refreshToken, now); +} + +export async function refreshStoredToken(options: AuthFlowOptions = {}): Promise { + const config = await loadSpotifyConfig(options); + const existing = await loadToken(options); + if (!existing) { + throw new Error("Spotify auth token not found. Run `scripts/spotify auth` first."); + } + const refreshed = await refreshAccessToken(config, existing.refreshToken, options.fetchImpl ?? fetch, options.now?.() ?? Date.now()); + await saveToken(refreshed, options); + return refreshed; +} + +export async function waitForAuthorizationCode(redirectUri: string, expectedState: string, timeoutMs = 300_000): Promise { + const redirect = new URL(redirectUri); + const hostname = redirect.hostname || "127.0.0.1"; + const port = Number(redirect.port); + if (!port || Number.isNaN(port)) { + throw new Error("Spotify redirectUri must include an explicit local port."); + } + + return new Promise((resolvePromise, reject) => { + let settled = false; + const server = createServer((request, response) => { + const finish = (callback: () => void): void => { + if (settled) { + return; + } + settled = true; + clearTimeout(timer); + server.close(); + callback(); + }; + try { + const requestUrl = new URL(request.url ?? "/", redirectUri); + if (requestUrl.pathname !== redirect.pathname) { + response.writeHead(404).end("Not found"); + return; + } + const error = requestUrl.searchParams.get("error"); + if (error) { + throw new Error(`Spotify authorization failed: ${error}`); + } + const state = requestUrl.searchParams.get("state"); + if (state !== expectedState) { + throw new Error("Spotify authorization state mismatch."); + } + const code = requestUrl.searchParams.get("code"); + if (!code) { + throw new Error("Spotify authorization callback did not include a code."); + } + response.writeHead(200, { "content-type": "text/plain" }).end("Spotify authorization complete. You can close this tab."); + finish(() => resolvePromise(code)); + } catch (error) { + response.writeHead(400, { "content-type": "text/plain" }).end("Spotify authorization failed."); + finish(() => reject(error)); + } + }); + const timer = setTimeout(() => { + if (settled) { + return; + } + settled = true; + server.close(); + reject(new Error("Spotify authorization timed out waiting for the browser callback.")); + }, timeoutMs); + + server.once("error", reject); + server.listen(port, hostname, () => { + const address = server.address() as AddressInfo; + if (address.port !== port) { + server.close(); + reject(new Error("Spotify authorization callback server bound to the wrong port.")); + } + }); + }); +} + +export async function runAuthorizationFlow(options: AuthFlowOptions = {}): Promise { + const config = await loadSpotifyConfig(options); + const verifier = createCodeVerifier(); + const state = createState(); + const authUrl = buildAuthorizeUrl(config, verifier, state); + await (options.openUrl ?? open)(authUrl); + const code = await waitForAuthorizationCode(config.redirectUri, state); + const token = await exchangeCodeForToken(config, code, verifier, options.fetchImpl ?? fetch, options.now?.() ?? Date.now()); + await saveToken(token, options); + return token; +} + +export async function getAuthStatus(options: ResolveConfigOptions & { now?: () => number } = {}): Promise { + let configFound = false; + try { + await loadSpotifyConfig(options); + configFound = true; + } catch { + configFound = false; + } + + const token = await loadToken(options); + return { + configFound, + tokenFound: Boolean(token), + tokenExpired: token ? token.expiresAt <= (options.now?.() ?? Date.now()) : null, + expiresAt: token?.expiresAt ?? null + }; +} diff --git a/skills/spotify/src/cli.ts b/skills/spotify/src/cli.ts index ee9f96d..eab8f5e 100644 --- a/skills/spotify/src/cli.ts +++ b/skills/spotify/src/cli.ts @@ -3,6 +3,8 @@ import minimist from "minimist"; import { fileURLToPath } from "node:url"; +import { getAuthStatus, runAuthorizationFlow } from "./auth.js"; + export interface CliDeps { stdout: Pick; stderr: Pick; @@ -37,8 +39,24 @@ function notImplemented(command: string): CommandHandler { export function createDefaultHandlers(): CommandHandlers { return { - auth: notImplemented("auth"), - status: notImplemented("status"), + auth: async (_args, deps) => { + await runAuthorizationFlow(); + deps.stdout.write("Spotify authorization complete.\n"); + return 0; + }, + status: async (args, deps) => { + const status = await getAuthStatus(); + if (args.json) { + deps.stdout.write(`${JSON.stringify(status, null, 2)}\n`); + } else { + deps.stdout.write(`Spotify config: ${status.configFound ? "found" : "missing"}\n`); + deps.stdout.write(`Spotify token: ${status.tokenFound ? "found" : "missing"}\n`); + if (status.tokenFound) { + deps.stdout.write(`Spotify token expired: ${status.tokenExpired ? "yes" : "no"}\n`); + } + } + return status.configFound && status.tokenFound ? 0 : 1; + }, search: notImplemented("search"), "list-playlists": notImplemented("list-playlists"), "create-playlist": notImplemented("create-playlist"), diff --git a/skills/spotify/src/config.ts b/skills/spotify/src/config.ts new file mode 100644 index 0000000..bda2cb5 --- /dev/null +++ b/skills/spotify/src/config.ts @@ -0,0 +1,116 @@ +import { access, readFile } from "node:fs/promises"; +import { constants } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { homedir } from "node:os"; + +import type { SpotifyConfig } from "./types.js"; + +export interface SpotifyPaths { + configDir: string; + configPath: string; + tokenPath: string; +} + +export interface ResolveConfigOptions { + env?: NodeJS.ProcessEnv; + startDir?: string; + homeDir?: string; +} + +const skillDir = resolve(dirname(fileURLToPath(import.meta.url)), ".."); + +function pathExists(path: string): Promise { + return access(path, constants.F_OK).then(() => true, () => false); +} + +function expandHome(path: string, homeDir: string): string { + if (path === "~") { + return homeDir; + } + if (path.startsWith("~/")) { + return join(homeDir, path.slice(2)); + } + return path; +} + +async function findUpwardCredentialDir(startDir: string): Promise { + let current = resolve(startDir); + while (true) { + const candidate = join(current, ".clawdbot", "credentials", "spotify"); + if (await pathExists(candidate)) { + return candidate; + } + const parent = dirname(current); + if (parent === current) { + return undefined; + } + current = parent; + } +} + +export async function resolveSpotifyPaths(options: ResolveConfigOptions = {}): Promise { + const env = options.env ?? process.env; + const homeDir = options.homeDir ?? homedir(); + + const candidates: Array> = []; + if (env.SPOTIFY_CONFIG_DIR) { + candidates.push(Promise.resolve(resolve(expandHome(env.SPOTIFY_CONFIG_DIR, homeDir)))); + } + candidates.push(Promise.resolve(join(homeDir, ".openclaw", "workspace", ".clawdbot", "credentials", "spotify"))); + candidates.push(findUpwardCredentialDir(options.startDir ?? skillDir)); + candidates.push(Promise.resolve(join(homeDir, ".clawdbot", "credentials", "spotify"))); + + for (const candidatePromise of candidates) { + const candidate = await candidatePromise; + if (candidate && await pathExists(candidate)) { + return { + configDir: candidate, + configPath: join(candidate, "config.json"), + tokenPath: join(candidate, "token.json") + }; + } + } + + const fallback = env.SPOTIFY_CONFIG_DIR + ? resolve(expandHome(env.SPOTIFY_CONFIG_DIR, homeDir)) + : join(homeDir, ".openclaw", "workspace", ".clawdbot", "credentials", "spotify"); + return { + configDir: fallback, + configPath: join(fallback, "config.json"), + tokenPath: join(fallback, "token.json") + }; +} + +export async function loadSpotifyConfig(options: ResolveConfigOptions = {}): Promise { + const paths = await resolveSpotifyPaths(options); + let raw: string; + try { + raw = await readFile(paths.configPath, "utf8"); + } catch { + throw new Error(`Spotify config not found. Create ${paths.configPath} with clientId and redirectUri.`); + } + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + throw new Error("Spotify config is not valid JSON."); + } + + if ( + !parsed || + typeof parsed !== "object" || + typeof (parsed as SpotifyConfig).clientId !== "string" || + typeof (parsed as SpotifyConfig).redirectUri !== "string" || + !(parsed as SpotifyConfig).clientId.trim() || + !(parsed as SpotifyConfig).redirectUri.trim() + ) { + throw new Error("Spotify config must include non-empty clientId and redirectUri."); + } + + return { + clientId: (parsed as SpotifyConfig).clientId, + redirectUri: (parsed as SpotifyConfig).redirectUri + }; +} diff --git a/skills/spotify/src/token-store.ts b/skills/spotify/src/token-store.ts new file mode 100644 index 0000000..6c8879c --- /dev/null +++ b/skills/spotify/src/token-store.ts @@ -0,0 +1,80 @@ +import { chmod, mkdir, readFile, rename, stat, writeFile } from "node:fs/promises"; +import { dirname } from "node:path"; + +import { resolveSpotifyPaths, type ResolveConfigOptions } from "./config.js"; +import type { SpotifyToken } from "./types.js"; + +export interface TokenStoreOptions extends ResolveConfigOptions { + tokenPath?: string; +} + +function isSpotifyToken(value: unknown): value is SpotifyToken { + return Boolean( + value && + typeof value === "object" && + typeof (value as SpotifyToken).accessToken === "string" && + typeof (value as SpotifyToken).refreshToken === "string" && + typeof (value as SpotifyToken).expiresAt === "number" + ); +} + +export async function resolveTokenPath(options: TokenStoreOptions = {}): Promise { + if (options.tokenPath) { + return options.tokenPath; + } + return (await resolveSpotifyPaths(options)).tokenPath; +} + +export async function loadToken(options: TokenStoreOptions = {}): Promise { + const tokenPath = await resolveTokenPath(options); + let raw: string; + try { + raw = await readFile(tokenPath, "utf8"); + } catch { + return undefined; + } + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + throw new Error("Spotify token store is not valid JSON."); + } + + if (!isSpotifyToken(parsed)) { + throw new Error("Spotify token store has an invalid token shape."); + } + return parsed; +} + +export async function saveToken(token: SpotifyToken, options: TokenStoreOptions = {}): Promise { + const tokenPath = await resolveTokenPath(options); + await mkdir(dirname(tokenPath), { recursive: true, mode: 0o700 }); + const tempPath = `${tokenPath}.${process.pid}.${Date.now()}.tmp`; + await writeFile(tempPath, `${JSON.stringify(token, null, 2)}\n`, { mode: 0o600 }); + try { + await chmod(tempPath, 0o600); + } catch { + // chmod can fail on non-POSIX filesystems; the initial mode still keeps normal macOS writes private. + } + await rename(tempPath, tokenPath); + try { + await chmod(tokenPath, 0o600); + } catch { + // Best effort only; token values are never printed. + } + return tokenPath; +} + +export function tokenNeedsRefresh(token: SpotifyToken, now = Date.now(), skewMs = 60_000): boolean { + return token.expiresAt <= now + skewMs; +} + +export async function tokenFileMode(options: TokenStoreOptions = {}): Promise { + const tokenPath = await resolveTokenPath(options); + try { + return (await stat(tokenPath)).mode & 0o777; + } catch { + return undefined; + } +} diff --git a/skills/spotify/tests/auth.test.ts b/skills/spotify/tests/auth.test.ts new file mode 100644 index 0000000..bbee650 --- /dev/null +++ b/skills/spotify/tests/auth.test.ts @@ -0,0 +1,126 @@ +import assert from "node:assert/strict"; +import { randomBytes } from "node:crypto"; +import { mkdtemp, writeFile } from "node:fs/promises"; +import { createServer } from "node:net"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { test } from "node:test"; + +import { + buildAuthorizeUrl, + createCodeChallenge, + createCodeVerifier, + exchangeCodeForToken, + getAuthStatus, + refreshAccessToken, + refreshStoredToken, + waitForAuthorizationCode +} from "../src/auth.js"; +import { loadToken, saveToken } from "../src/token-store.js"; +import type { SpotifyConfig } from "../src/types.js"; + +const config: SpotifyConfig = { + clientId: "client-id", + redirectUri: "http://127.0.0.1:8888/callback" +}; + +async function getFreePort(): Promise { + return new Promise((resolve, reject) => { + const server = createServer(); + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + server.close(() => { + if (!address || typeof address === "string") { + reject(new Error("Unable to reserve a test port.")); + return; + } + resolve(address.port); + }); + }); + }); +} + +test("creates RFC7636 S256 code challenge", () => { + assert.equal( + createCodeChallenge("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"), + "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM" + ); +}); + +test("creates url-safe verifier", () => { + const verifier = createCodeVerifier(randomBytes(64)); + assert.match(verifier, /^[A-Za-z0-9_-]+$/); +}); + +test("builds authorize url with PKCE parameters and scopes", () => { + const url = new URL(buildAuthorizeUrl(config, "verifier", "state", ["playlist-read-private"])); + + assert.equal(url.origin + url.pathname, "https://accounts.spotify.com/authorize"); + assert.equal(url.searchParams.get("response_type"), "code"); + assert.equal(url.searchParams.get("client_id"), "client-id"); + assert.equal(url.searchParams.get("redirect_uri"), config.redirectUri); + assert.equal(url.searchParams.get("state"), "state"); + assert.equal(url.searchParams.get("code_challenge_method"), "S256"); + assert.equal(url.searchParams.get("scope"), "playlist-read-private"); +}); + +test("exchanges code for token with PKCE body", async () => { + const calls: Array<{ url: string; body: URLSearchParams }> = []; + const fetchImpl = async (url: string | URL | Request, init?: RequestInit) => { + calls.push({ url: String(url), body: init?.body as URLSearchParams }); + return new Response(JSON.stringify({ access_token: "access", refresh_token: "refresh", expires_in: 10 }), { status: 200 }); + }; + + const token = await exchangeCodeForToken(config, "code", "verifier", fetchImpl as typeof fetch, 1_000); + + assert.deepEqual(token, { accessToken: "access", refreshToken: "refresh", expiresAt: 11_000 }); + assert.equal(calls[0].url, "https://accounts.spotify.com/api/token"); + assert.equal(calls[0].body.get("grant_type"), "authorization_code"); + assert.equal(calls[0].body.get("code_verifier"), "verifier"); +}); + +test("refresh preserves old refresh token when response omits one", async () => { + const fetchImpl = async () => new Response(JSON.stringify({ access_token: "new-access", expires_in: 10 }), { status: 200 }); + + const token = await refreshAccessToken(config, "old-refresh", fetchImpl as typeof fetch, 1_000); + + assert.deepEqual(token, { accessToken: "new-access", refreshToken: "old-refresh", expiresAt: 11_000 }); +}); + +test("refreshStoredToken persists refreshed token", async () => { + const root = await mkdtemp(join(tmpdir(), "spotify-auth-")); + await writeFile(join(root, "config.json"), JSON.stringify(config)); + await saveToken({ accessToken: "old", refreshToken: "refresh", expiresAt: 1 }, { tokenPath: join(root, "token.json") }); + const fetchImpl = async () => new Response(JSON.stringify({ access_token: "new", expires_in: 5 }), { status: 200 }); + + const token = await refreshStoredToken({ env: { SPOTIFY_CONFIG_DIR: root }, homeDir: "/missing", fetchImpl: fetchImpl as typeof fetch, now: () => 100 }); + + assert.deepEqual(token, { accessToken: "new", refreshToken: "refresh", expiresAt: 5_100 }); + assert.deepEqual(await loadToken({ tokenPath: join(root, "token.json") }), token); +}); + +test("getAuthStatus reports config and token state without token values", async () => { + const root = await mkdtemp(join(tmpdir(), "spotify-auth-")); + await writeFile(join(root, "config.json"), JSON.stringify(config)); + await saveToken({ accessToken: "secret-access", refreshToken: "secret-refresh", expiresAt: 200 }, { tokenPath: join(root, "token.json") }); + + const status = await getAuthStatus({ env: { SPOTIFY_CONFIG_DIR: root }, homeDir: "/missing", now: () => 100 }); + + assert.deepEqual(status, { + configFound: true, + tokenFound: true, + tokenExpired: false, + expiresAt: 200 + }); + assert.equal(JSON.stringify(status).includes("secret"), false); +}); + +test("authorization callback wait has a bounded timeout", async () => { + const port = await getFreePort(); + + await assert.rejects( + () => waitForAuthorizationCode(`http://127.0.0.1:${port}/callback`, "state", 1), + /timed out/ + ); +}); diff --git a/skills/spotify/tests/cli.test.ts b/skills/spotify/tests/cli.test.ts index 56d748b..c1f9dff 100644 --- a/skills/spotify/tests/cli.test.ts +++ b/skills/spotify/tests/cli.test.ts @@ -71,15 +71,15 @@ test("dispatches known command with json flag", async () => { assert.equal(stderr.output(), ""); }); -test("known commands return structured placeholder errors in json mode", async () => { +test("known unimplemented commands return structured placeholder errors in json mode", async () => { const stdout = createBuffer(); const stderr = createBuffer(); - const code = await runCli(["status", "--json"], { stdout: stdout.stream, stderr: stderr.stream }); + const code = await runCli(["search", "--json"], { stdout: stdout.stream, stderr: stderr.stream }); assert.equal(code, 2); assert.deepEqual(JSON.parse(stdout.output()), { ok: false, - error: "Command not implemented yet: status" + error: "Command not implemented yet: search" }); assert.equal(stderr.output(), ""); }); diff --git a/skills/spotify/tests/config.test.ts b/skills/spotify/tests/config.test.ts new file mode 100644 index 0000000..deae347 --- /dev/null +++ b/skills/spotify/tests/config.test.ts @@ -0,0 +1,51 @@ +import assert from "node:assert/strict"; +import { mkdtemp, mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { test } from "node:test"; + +import { loadSpotifyConfig, resolveSpotifyPaths } from "../src/config.js"; + +test("uses SPOTIFY_CONFIG_DIR when it exists", async () => { + const root = await mkdtemp(join(tmpdir(), "spotify-config-")); + await writeFile(join(root, "config.json"), JSON.stringify({ clientId: "id", redirectUri: "http://127.0.0.1:8888/callback" })); + + const paths = await resolveSpotifyPaths({ env: { SPOTIFY_CONFIG_DIR: root }, homeDir: "/missing-home" }); + + assert.equal(paths.configDir, root); + assert.equal(paths.configPath, join(root, "config.json")); + assert.equal(paths.tokenPath, join(root, "token.json")); +}); + +test("loads and validates config json", async () => { + const root = await mkdtemp(join(tmpdir(), "spotify-config-")); + await writeFile(join(root, "config.json"), JSON.stringify({ clientId: "client", redirectUri: "http://127.0.0.1:8888/callback" })); + + const config = await loadSpotifyConfig({ env: { SPOTIFY_CONFIG_DIR: root }, homeDir: "/missing-home" }); + + assert.deepEqual(config, { + clientId: "client", + redirectUri: "http://127.0.0.1:8888/callback" + }); +}); + +test("finds upward clawdbot credentials", async () => { + const root = await mkdtemp(join(tmpdir(), "spotify-upward-")); + const configDir = join(root, ".clawdbot", "credentials", "spotify"); + const nested = join(root, "a", "b"); + await mkdir(configDir, { recursive: true }); + await mkdir(nested, { recursive: true }); + + const paths = await resolveSpotifyPaths({ env: {}, startDir: nested, homeDir: "/missing-home" }); + + assert.equal(paths.configDir, configDir); +}); + +test("rejects missing config file", async () => { + const root = await mkdtemp(join(tmpdir(), "spotify-config-")); + + await assert.rejects( + () => loadSpotifyConfig({ env: { SPOTIFY_CONFIG_DIR: root }, homeDir: "/missing-home" }), + /Spotify config not found/ + ); +}); diff --git a/skills/spotify/tests/token-store.test.ts b/skills/spotify/tests/token-store.test.ts new file mode 100644 index 0000000..66773dd --- /dev/null +++ b/skills/spotify/tests/token-store.test.ts @@ -0,0 +1,44 @@ +import assert from "node:assert/strict"; +import { mkdtemp, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { test } from "node:test"; + +import { loadToken, saveToken, tokenFileMode, tokenNeedsRefresh } from "../src/token-store.js"; + +test("saves and loads token without changing values", async () => { + const root = await mkdtemp(join(tmpdir(), "spotify-token-")); + const tokenPath = join(root, "token.json"); + const token = { + accessToken: "access-secret", + refreshToken: "refresh-secret", + expiresAt: 123456 + }; + + await saveToken(token, { tokenPath }); + assert.deepEqual(await loadToken({ tokenPath }), token); +}); + +test("writes token file with owner-only mode when supported", async () => { + const root = await mkdtemp(join(tmpdir(), "spotify-token-")); + const tokenPath = join(root, "token.json"); + + await saveToken({ accessToken: "a", refreshToken: "r", expiresAt: 123 }, { tokenPath }); + const mode = await tokenFileMode({ tokenPath }); + + assert.equal(mode, 0o600); +}); + +test("rejects invalid token shape", async () => { + const root = await mkdtemp(join(tmpdir(), "spotify-token-")); + const tokenPath = join(root, "token.json"); + await saveToken({ accessToken: "a", refreshToken: "r", expiresAt: 123 }, { tokenPath }); + + await writeFile(tokenPath, JSON.stringify({ accessToken: "a" })); + await assert.rejects(() => loadToken({ tokenPath }), /invalid token shape/); +}); + +test("identifies tokens needing refresh with skew", () => { + assert.equal(tokenNeedsRefresh({ accessToken: "a", refreshToken: "r", expiresAt: 1_050 }, 1_000, 100), true); + assert.equal(tokenNeedsRefresh({ accessToken: "a", refreshToken: "r", expiresAt: 1_200 }, 1_000, 100), false); +});