Files
teamspeak-music-bot/src/music/spotify/spotify-oauth.test.ts
T

481 lines
17 KiB
TypeScript

import { describe, it, expect, vi } from "vitest";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
SpotifyOAuth,
SPOTIFY_CONTROL_SCOPES,
generateCodeVerifier,
codeChallengeS256,
createFileOAuthTokenStore,
type OAuthTokens,
type OAuthTokenStore,
} from "./spotify-oauth.js";
// Correction C3.2: the control OAuth REQUIRES a user-provided client_id (their
// own Spotify Developer app) + caller-supplied loopback redirect. There is NO
// librespot public-client / :5588 default.
const CLIENT_ID = "test-client-id";
const REDIRECT_URI = "http://127.0.0.1:8888/api/spotify/callback";
/** In-memory store exposing `.value` so tests can assert persistence. */
function memStore(
initial: OAuthTokens | null = null,
): OAuthTokenStore & { value: OAuthTokens | null } {
const s = {
value: initial,
load() {
return s.value;
},
save(t: OAuthTokens) {
s.value = t;
},
clear() {
s.value = null;
},
};
return s;
}
/** A manually-settled promise, to hold a token POST "in flight" during a test. */
function deferred<T>() {
let resolve!: (v: T) => void;
let reject!: (e: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
describe("PKCE helpers", () => {
it("generateCodeVerifier returns 64 chars from the unreserved set", () => {
const v = generateCodeVerifier();
expect(v).toHaveLength(64);
expect(v).toMatch(/^[A-Za-z0-9\-._~]{64}$/);
expect(generateCodeVerifier()).not.toBe(v); // random
});
it("codeChallengeS256 is base64url(sha256) with no padding (43 chars)", () => {
const c = codeChallengeS256("abc123");
expect(c).toHaveLength(43); // 32-byte digest -> 43 base64url chars
expect(c).not.toContain("=");
expect(c).toMatch(/^[A-Za-z0-9_-]+$/);
});
});
describe("SpotifyOAuth.buildAuthorizeUrl", () => {
it("builds accounts.spotify.com/authorize with the caller's clientId + redirectUri + S256", () => {
const oauth = new SpotifyOAuth({
clientId: CLIENT_ID,
redirectUri: REDIRECT_URI,
store: memStore(),
});
const { url, state } = oauth.buildAuthorizeUrl();
const u = new URL(url);
expect(u.origin + u.pathname).toBe("https://accounts.spotify.com/authorize");
const p = u.searchParams;
expect(p.get("client_id")).toBe(CLIENT_ID);
expect(p.get("response_type")).toBe("code");
expect(p.get("redirect_uri")).toBe(REDIRECT_URI);
expect(p.get("code_challenge_method")).toBe("S256");
expect(p.get("code_challenge")).toHaveLength(43);
expect(p.get("scope")).toBe(SPOTIFY_CONTROL_SCOPES);
expect(p.get("state")).toBe(state);
expect(state).toMatch(/^[0-9a-f]{32}$/);
expect(oauth.getClientId()).toBe(CLIENT_ID);
expect(oauth.getRedirectUri()).toBe(REDIRECT_URI);
});
// Correction C3.2: no clientId => cannot start OAuth; throw a clear message.
it("throws a clear error when clientId is empty", () => {
const oauth = new SpotifyOAuth({ redirectUri: REDIRECT_URI, store: memStore() });
expect(() => oauth.buildAuthorizeUrl()).toThrow(/Client ID/i);
});
});
describe("SpotifyOAuth.isAuthorized (C3.2)", () => {
it("is false without a clientId even if a refresh token is stored", () => {
const store = memStore({
accessToken: "a",
refreshToken: "r",
expiresAt: Date.now() + 60_000,
scope: "s",
});
const oauth = new SpotifyOAuth({ redirectUri: REDIRECT_URI, store });
expect(oauth.isAuthorized()).toBe(false);
});
it("is true with a clientId and a stored refresh token", () => {
const store = memStore({
accessToken: "a",
refreshToken: "r",
expiresAt: Date.now() + 60_000,
scope: "s",
});
const oauth = new SpotifyOAuth({ clientId: CLIENT_ID, store });
expect(oauth.isAuthorized()).toBe(true);
});
});
describe("SpotifyOAuth.handleCallback", () => {
it("exchanges the code (PKCE verifier matches the authorize challenge) and persists tokens", async () => {
const store = memStore();
const http = {
post: vi.fn().mockResolvedValue({
data: {
access_token: "a1",
refresh_token: "r1",
expires_in: 3600,
scope: SPOTIFY_CONTROL_SCOPES,
},
}),
} as any;
const oauth = new SpotifyOAuth({
clientId: CLIENT_ID,
redirectUri: REDIRECT_URI,
store,
deps: { http },
});
const { url, state } = oauth.buildAuthorizeUrl();
const challenge = new URL(url).searchParams.get("code_challenge")!;
const ok = await oauth.handleCallback("CODE123", state);
expect(ok).toBe(true);
const [path, bodyStr, cfg] = http.post.mock.calls[0];
expect(path).toBe("/api/token");
expect(cfg.headers["Content-Type"]).toBe("application/x-www-form-urlencoded");
const body = new URLSearchParams(bodyStr as string);
expect(body.get("grant_type")).toBe("authorization_code");
expect(body.get("code")).toBe("CODE123");
expect(body.get("redirect_uri")).toBe(REDIRECT_URI);
expect(body.get("client_id")).toBe(CLIENT_ID);
// The verifier sent MUST hash to the challenge advertised in the authorize URL.
const verifier = body.get("code_verifier")!;
expect(codeChallengeS256(verifier)).toBe(challenge);
expect(store.value?.accessToken).toBe("a1");
expect(store.value?.refreshToken).toBe("r1");
expect(store.value?.expiresAt).toBeGreaterThan(Date.now());
expect(oauth.isAuthorized()).toBe(true);
});
it("rejects an unknown state without calling the token endpoint (CSRF guard)", async () => {
const http = { post: vi.fn() } as any;
const oauth = new SpotifyOAuth({
clientId: CLIENT_ID,
redirectUri: REDIRECT_URI,
store: memStore(),
deps: { http },
});
expect(await oauth.handleCallback("CODE", "not-a-real-state")).toBe(false);
expect(http.post).not.toHaveBeenCalled();
});
// Correction C3.7: the state->verifier entry is deleted on EVERY terminal
// path (finally), so a failed login never leaks it and cannot be replayed.
it("deletes the pending verifier even when the token exchange fails", async () => {
const http = {
post: vi.fn().mockRejectedValue({
response: { status: 400, data: { error: "invalid_grant" } },
}),
} as any;
const oauth = new SpotifyOAuth({
clientId: CLIENT_ID,
redirectUri: REDIRECT_URI,
store: memStore(),
deps: { http },
});
const { state } = oauth.buildAuthorizeUrl();
// First attempt fails at the network/token step.
expect(await oauth.handleCallback("CODE", state)).toBe(false);
expect(http.post).toHaveBeenCalledTimes(1);
// Replaying the same state now fails the CSRF guard (verifier was deleted),
// WITHOUT hitting the token endpoint again.
expect(await oauth.handleCallback("CODE", state)).toBe(false);
expect(http.post).toHaveBeenCalledTimes(1);
});
});
describe("SpotifyOAuth.getAccessToken", () => {
it("returns the cached token without refreshing when still valid", async () => {
const http = { post: vi.fn() } as any;
const store = memStore({
accessToken: "cached",
refreshToken: "r1",
expiresAt: Date.now() + 60_000,
scope: "s",
});
const oauth = new SpotifyOAuth({ clientId: CLIENT_ID, store, deps: { http } });
expect(await oauth.getAccessToken()).toBe("cached");
expect(http.post).not.toHaveBeenCalled();
});
it("refreshes when expired and persists the ROTATED refresh token", async () => {
const store = memStore({
accessToken: "old",
refreshToken: "r1",
expiresAt: Date.now() - 1000,
scope: "s",
});
const http = {
post: vi.fn().mockResolvedValue({
data: { access_token: "a2", refresh_token: "r2", expires_in: 3600 },
}),
} as any;
const oauth = new SpotifyOAuth({ clientId: CLIENT_ID, store, deps: { http } });
expect(await oauth.getAccessToken()).toBe("a2");
const body = new URLSearchParams(http.post.mock.calls[0][1] as string);
expect(body.get("grant_type")).toBe("refresh_token");
expect(body.get("refresh_token")).toBe("r1");
expect(body.get("client_id")).toBe(CLIENT_ID);
expect(store.value?.refreshToken).toBe("r2"); // rotated + persisted
expect(store.value?.accessToken).toBe("a2");
});
it("keeps the old refresh token when the refresh response omits a new one", async () => {
const store = memStore({
accessToken: "old",
refreshToken: "r1",
expiresAt: Date.now() - 1000,
scope: "s",
});
const http = {
post: vi.fn().mockResolvedValue({ data: { access_token: "a2", expires_in: 3600 } }),
} as any;
const oauth = new SpotifyOAuth({ clientId: CLIENT_ID, store, deps: { http } });
expect(await oauth.getAccessToken()).toBe("a2");
expect(store.value?.refreshToken).toBe("r1");
});
it("clears the store and returns null on invalid_grant (expired refresh token)", async () => {
const store = memStore({
accessToken: "old",
refreshToken: "r1",
expiresAt: Date.now() - 1000,
scope: "s",
});
const http = {
post: vi.fn().mockRejectedValue({
response: { status: 400, data: { error: "invalid_grant" } },
}),
} as any;
const oauth = new SpotifyOAuth({ clientId: CLIENT_ID, store, deps: { http } });
expect(await oauth.getAccessToken()).toBeNull();
expect(store.value).toBeNull();
expect(oauth.isAuthorized()).toBe(false);
});
it("returns null when unauthorized (no stored refresh token)", async () => {
const http = { post: vi.fn() } as any;
const oauth = new SpotifyOAuth({
clientId: CLIENT_ID,
store: memStore(),
deps: { http },
});
expect(await oauth.getAccessToken()).toBeNull();
expect(oauth.isAuthorized()).toBe(false);
expect(http.post).not.toHaveBeenCalled();
});
});
// S4.3: refresh-token rotation makes two concurrent refreshes race — the second
// would POST with a token the first already invalidated. Collapse them into one.
describe("SpotifyOAuth.getAccessToken (in-flight refresh, S4.3)", () => {
it("collapses concurrent refreshes into a single token POST", async () => {
let t = 0;
const now = () => t;
const store = memStore({
accessToken: "old",
refreshToken: "r1",
expiresAt: 0, // now()=0 is not < 0 -> expired -> must refresh
scope: "s",
});
const d = deferred<any>();
const http = { post: vi.fn().mockReturnValue(d.promise) } as any;
const oauth = new SpotifyOAuth({
clientId: CLIENT_ID,
store,
deps: { http, now },
});
// Fire two calls while the POST is still pending.
const p1 = oauth.getAccessToken();
const p2 = oauth.getAccessToken();
expect(http.post).toHaveBeenCalledTimes(1); // collapsed to ONE POST
d.resolve({
data: { access_token: "a2", refresh_token: "r2", expires_in: 3600 },
});
expect(await p1).toBe("a2");
expect(await p2).toBe("a2");
expect(http.post).toHaveBeenCalledTimes(1);
expect(store.value?.refreshToken).toBe("r2"); // rotated once, not twice
});
it("clears the in-flight refresh after it settles, allowing a later refresh", async () => {
let t = 0;
const now = () => t;
const store = memStore({
accessToken: "old",
refreshToken: "r1",
expiresAt: 0,
scope: "s",
});
const http = {
post: vi
.fn()
.mockResolvedValueOnce({
data: { access_token: "a2", refresh_token: "r2", expires_in: 3600 },
})
.mockResolvedValueOnce({
data: { access_token: "a3", refresh_token: "r3", expires_in: 3600 },
}),
} as any;
const oauth = new SpotifyOAuth({
clientId: CLIENT_ID,
store,
deps: { http, now },
});
expect(await oauth.getAccessToken()).toBe("a2");
expect(http.post).toHaveBeenCalledTimes(1);
// toTokens now uses this.now(): saved expiresAt = 0 + 3600s - 30s skew.
// Still valid at t=0 -> cached, no new POST.
expect(await oauth.getAccessToken()).toBe("a2");
expect(http.post).toHaveBeenCalledTimes(1);
// Advance past the newly-saved expiry -> in-flight was cleared, so a fresh
// refresh fires (proves .finally() reset refreshInFlight).
t = 3600 * 1000;
expect(await oauth.getAccessToken()).toBe("a3");
expect(http.post).toHaveBeenCalledTimes(2);
expect(store.value?.refreshToken).toBe("r3");
});
});
// S4.3: bound the PKCE verifier map so abandoned logins can't accumulate.
describe("SpotifyOAuth PKCE verifier TTL + cap (S4.3)", () => {
it("expires a pending verifier after the TTL (handleCallback returns false)", async () => {
let t = 0;
const now = () => t;
const http = { post: vi.fn() } as any;
const oauth = new SpotifyOAuth({
clientId: CLIENT_ID,
redirectUri: REDIRECT_URI,
store: memStore(),
deps: { http, now },
});
const { state } = oauth.buildAuthorizeUrl();
t = 10 * 60 * 1000 + 1; // VERIFIER_TTL_MS + 1
expect(await oauth.handleCallback("CODE", state)).toBe(false);
expect(http.post).not.toHaveBeenCalled(); // never reached the token step
});
it("caps the verifier map, evicting the oldest state (behavioral)", async () => {
let t = 0;
const now = () => t;
const http = {
post: vi.fn().mockResolvedValue({
data: { access_token: "a1", refresh_token: "r1", expires_in: 3600 },
}),
} as any;
const oauth = new SpotifyOAuth({
clientId: CLIENT_ID,
redirectUri: REDIRECT_URI,
store: memStore(),
deps: { http, now },
});
// First (oldest) state, then enough more to exceed VERIFIER_MAX (32).
const first = oauth.buildAuthorizeUrl().state;
let last = first;
for (let i = 0; i < 32; i++) last = oauth.buildAuthorizeUrl().state;
// The oldest was evicted -> unknown state -> CSRF guard, no token POST.
expect(await oauth.handleCallback("CODE", first)).toBe(false);
expect(http.post).not.toHaveBeenCalled();
// A still-pending (newest) state DOES resolve -> only the oldest was dropped.
expect(await oauth.handleCallback("CODE", last)).toBe(true);
expect(http.post).toHaveBeenCalledTimes(1);
});
});
// Whole-branch I2: a UI-entered Client ID (saved in Settings) must reach the
// single live SpotifyOAuth WITHOUT a process restart. configure() re-arms the
// runtime credentials; an empty clientId re-disables OAuth.
describe("SpotifyOAuth.configure (runtime credentials, whole-branch I2)", () => {
it("applies a UI-entered clientId + redirectUri so OAuth arms without a restart", () => {
// Fresh install: boot-time config had no clientId, so OAuth is disabled.
const store = memStore({
accessToken: "a",
refreshToken: "r",
expiresAt: Date.now() + 60_000,
scope: "s",
});
const oauth = new SpotifyOAuth({ clientId: "", store });
expect(oauth.isAuthorized()).toBe(false);
expect(() => oauth.buildAuthorizeUrl()).toThrow(/Client ID/i);
// Operator enters creds in Settings -> POST /settings calls configure().
const REDIRECT = "http://127.0.0.1:3000/api/spotify/callback";
oauth.configure("cid", REDIRECT);
expect(oauth.getClientId()).toBe("cid");
expect(oauth.getRedirectUri()).toBe(REDIRECT);
// Now authorize + isAuthorized work against the newly-supplied app.
expect(oauth.isAuthorized()).toBe(true);
const { url } = oauth.buildAuthorizeUrl();
const p = new URL(url).searchParams;
expect(p.get("client_id")).toBe("cid");
expect(p.get("redirect_uri")).toBe(REDIRECT);
});
it("trims the clientId and re-disables OAuth when cleared", () => {
const oauth = new SpotifyOAuth({
clientId: "old",
redirectUri: "http://old",
store: memStore(),
});
oauth.configure(" cid ", "http://127.0.0.1:3000/api/spotify/callback");
expect(oauth.getClientId()).toBe("cid"); // trimmed
// Empty clientId disables OAuth again (gate on buildAuthorizeUrl/isAuthorized).
oauth.configure("");
expect(oauth.getClientId()).toBe("");
expect(oauth.getRedirectUri()).toBe("");
expect(oauth.isAuthorized()).toBe(false);
expect(() => oauth.buildAuthorizeUrl()).toThrow(/Client ID/i);
});
});
describe("createFileOAuthTokenStore", () => {
it("round-trips save/load and clear() removes it", () => {
const dir = mkdtempSync(join(tmpdir(), "sp-oauth-"));
const file = join(dir, "nested", "tokens.json");
try {
const store = createFileOAuthTokenStore(file);
expect(store.load()).toBeNull(); // missing file
const t: OAuthTokens = {
accessToken: "a",
refreshToken: "r",
expiresAt: 123,
scope: "s",
};
store.save(t);
expect(store.load()).toEqual(t);
store.clear();
expect(store.load()).toBeNull();
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});