feat(spotify): implement milestone M2 auth
This commit is contained in:
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