feat(spotify): implement milestone M2 auth
This commit is contained in:
230
skills/spotify/src/auth.ts
Normal file
230
skills/spotify/src/auth.ts
Normal file
@@ -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<unknown>;
|
||||
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<SpotifyToken> {
|
||||
if (!response.ok) {
|
||||
throw new Error(`Spotify token request failed with status ${response.status}.`);
|
||||
}
|
||||
const body = await response.json() as Partial<TokenEndpointResponse>;
|
||||
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<SpotifyToken> {
|
||||
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<SpotifyToken> {
|
||||
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<SpotifyToken> {
|
||||
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<string> {
|
||||
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<SpotifyToken> {
|
||||
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<AuthStatus> {
|
||||
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
|
||||
};
|
||||
}
|
||||
@@ -3,6 +3,8 @@
|
||||
import minimist from "minimist";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { getAuthStatus, runAuthorizationFlow } from "./auth.js";
|
||||
|
||||
export interface CliDeps {
|
||||
stdout: Pick<NodeJS.WriteStream, "write">;
|
||||
stderr: Pick<NodeJS.WriteStream, "write">;
|
||||
@@ -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"),
|
||||
|
||||
116
skills/spotify/src/config.ts
Normal file
116
skills/spotify/src/config.ts
Normal file
@@ -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<boolean> {
|
||||
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<string | undefined> {
|
||||
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<SpotifyPaths> {
|
||||
const env = options.env ?? process.env;
|
||||
const homeDir = options.homeDir ?? homedir();
|
||||
|
||||
const candidates: Array<Promise<string | undefined>> = [];
|
||||
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<SpotifyConfig> {
|
||||
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
|
||||
};
|
||||
}
|
||||
80
skills/spotify/src/token-store.ts
Normal file
80
skills/spotify/src/token-store.ts
Normal file
@@ -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<string> {
|
||||
if (options.tokenPath) {
|
||||
return options.tokenPath;
|
||||
}
|
||||
return (await resolveSpotifyPaths(options)).tokenPath;
|
||||
}
|
||||
|
||||
export async function loadToken(options: TokenStoreOptions = {}): Promise<SpotifyToken | undefined> {
|
||||
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<string> {
|
||||
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<number | undefined> {
|
||||
const tokenPath = await resolveTokenPath(options);
|
||||
try {
|
||||
return (await stat(tokenPath)).mode & 0o777;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
126
skills/spotify/tests/auth.test.ts
Normal file
126
skills/spotify/tests/auth.test.ts
Normal file
@@ -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<number> {
|
||||
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/
|
||||
);
|
||||
});
|
||||
@@ -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(), "");
|
||||
});
|
||||
|
||||
51
skills/spotify/tests/config.test.ts
Normal file
51
skills/spotify/tests/config.test.ts
Normal file
@@ -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/
|
||||
);
|
||||
});
|
||||
44
skills/spotify/tests/token-store.test.ts
Normal file
44
skills/spotify/tests/token-store.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user