Something went wrong. Try again.
[READ-ONLY] Mirror of https://github.com/improsocial/impro
An extensible Bluesky client for web impro.social
Something went wrong. Try again.
JavaScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926import { describe, it, beforeEach, afterEach, mock } from "node:test";import assert from "node:assert/strict";import { MockFetch, mockWindowLocation } from "../testHelpers.js";import { Auth, BasicAuthProvider, BasicAuthSession, RefreshTokenError, getMissingScopes,} from "/js/auth.js";import { TimeoutError } from "/js/utils.js";
const originalWindow = globalThis.window;const originalPath = window.location.pathname + window.location.search + window.location.hash;
// Produces a minimal JWT string whose payload encodes the given fields.// parseJwt only decodes — no signature verification — so the sig can be fake.function makeJwt(payload) { const encode = (obj) => btoa(JSON.stringify(obj)) .replace(/\+/g, "-") .replace(/\//g, "_") .replace(/=/g, ""); return `${encode({ alg: "HS256" })}.${encode(payload)}.fakesig`;}
// Writes a BasicAuth session to localStorage so BasicAuth.getSession() returns// a live session without hitting the network.function writeBasicAuthSession({ aud = "did:web:pds.example.com", sub = "did:plc:test",} = {}) { const accessJwt = makeJwt({ aud, sub }); const refreshJwt = makeJwt({ sub }); localStorage.setItem("accessJwt", accessJwt); localStorage.setItem("refreshJwt", refreshJwt); return { accessJwt, refreshJwt };}
function makeMockProvider({ logoutFn } = {}) { return { logout: mock.fn(logoutFn ?? (() => Promise.resolve())), getSession: mock.fn(() => Promise.resolve(null)), };}
describe("Auth constructor", () => { it("throws when no provider is given", () => { let threw = null; try { new Auth(null); } catch (error) { threw = error; } assert(threw !== null); assert(threw.message.includes("provider")); });});
describe("Auth.handleForceLogoutParam", () => { afterEach(() => { globalThis.window = originalWindow; window.history.replaceState(null, "", originalPath); });
it("does not call provider.logout when param is absent", async () => { const provider = makeMockProvider(); const manager = new Auth(provider); await manager.handleForceLogoutParam(); assert.deepEqual(provider.logout.mock.callCount(), 0); });
it("calls provider.logout when force-logout param is present", async () => { mockWindowLocation("?force-logout=1"); const provider = makeMockProvider(); const manager = new Auth(provider); // Don't await — the returned promise never resolves; flush microtasks instead manager.handleForceLogoutParam(); await Promise.resolve(); await Promise.resolve(); assert.deepEqual(provider.logout.mock.callCount(), 1); });
it("redirects to the login page after logout", async () => { const capturedHrefs = mockWindowLocation("?force-logout=1"); const manager = new Auth(makeMockProvider()); manager.handleForceLogoutParam(); await Promise.resolve(); await Promise.resolve(); assert(capturedHrefs.length > 0, "expected a redirect"); assert(capturedHrefs[0].includes("/login")); });
it("still redirects to login when provider.logout throws", async () => { const capturedHrefs = mockWindowLocation("?force-logout=1"); const manager = new Auth( makeMockProvider({ logoutFn: () => Promise.reject(new Error("logout failed")), }), ); manager.handleForceLogoutParam(); await Promise.resolve(); await Promise.resolve(); await Promise.resolve(); assert( capturedHrefs.length > 0, "expected a redirect despite logout error", ); assert(capturedHrefs[0].includes("/login")); });
it("strips force-logout from the URL before building returnTo so login does not loop", async () => { // Simulate being on /profile/alice?force-logout so linkToLogin() would // normally encode ?force-logout into returnTo, causing a logout loop on return. const capturedHrefs = []; let currentPathname = "/profile/alice"; let currentSearch = "?force-logout=1&tab=posts"; let currentHash = ""; const locationMock = { get search() { return currentSearch; }, get pathname() { return currentPathname; }, get hash() { return currentHash; }, get href() { return ( capturedHrefs.at(-1) ?? "http://localhost/profile/alice?force-logout=1&tab=posts" ); }, set href(value) { capturedHrefs.push(value); }, }; const historyMock = { replaceState(_state, _title, url) { const parsed = new URL(url, "http://localhost"); currentPathname = parsed.pathname; currentSearch = parsed.search; currentHash = parsed.hash; }, }; globalThis.window = new Proxy(originalWindow, { get(target, prop) { if (prop === "location") return locationMock; if (prop === "history") return historyMock; const val = target[prop]; return typeof val === "function" ? val.bind(target) : val; }, }); const manager = new Auth(makeMockProvider()); manager.handleForceLogoutParam(); await Promise.resolve(); await Promise.resolve(); assert(capturedHrefs.length > 0, "expected a redirect"); assert(capturedHrefs[0].includes("/login"), "should redirect to login"); const decoded = decodeURIComponent(capturedHrefs[0]); assert( !decoded.includes("force-logout"), "force-logout must not appear in the returnTo", ); assert( decoded.includes("tab=posts"), "other params should be preserved in returnTo", ); });});
describe("BasicAuthSession", () => { beforeEach(() => { globalThis.fetch = new MockFetch(); });
afterEach(() => { localStorage.clear(); delete globalThis.fetch; });
it("fromLocalStorage returns null when no tokens are stored", () => { assert.deepEqual(BasicAuthSession.fromLocalStorage(), null); });
it("fromLocalStorage returns null when only one token is stored", () => { localStorage.setItem("accessJwt", makeJwt({ sub: "did:plc:test" })); assert.deepEqual(BasicAuthSession.fromLocalStorage(), null); });
it("save and fromLocalStorage round-trip the tokens", () => { const accessJwt = makeJwt({ sub: "did:plc:test", aud: "did:web:pds.example.com", }); const refreshJwt = makeJwt({ sub: "did:plc:test" }); const session = new BasicAuthSession(accessJwt, refreshJwt); session.save(); const loaded = BasicAuthSession.fromLocalStorage(); assert(loaded !== null); assert.deepEqual(loaded.accessJwt, accessJwt); assert.deepEqual(loaded.refreshJwt, refreshJwt); });
it("delete removes both tokens from localStorage", async () => { const { accessJwt, refreshJwt } = writeBasicAuthSession(); const session = new BasicAuthSession(accessJwt, refreshJwt); await session.delete(); assert.deepEqual(localStorage.getItem("accessJwt"), null); assert.deepEqual(localStorage.getItem("refreshJwt"), null); });
it("serviceEndpoint decodes aud from JWT and converts did:web: to https://", () => { const session = new BasicAuthSession( makeJwt({ aud: "did:web:pds.example.com", sub: "did:plc:test" }), makeJwt({}), ); assert.deepEqual(session.serviceEndpoint, "https://pds.example.com"); });
it("did decodes sub from JWT", () => { const session = new BasicAuthSession( makeJwt({ aud: "did:web:pds.example.com", sub: "did:plc:alice" }), makeJwt({}), ); assert.deepEqual(session.did, "did:plc:alice"); });
it("fetch passes the Bearer token and returns the response", async () => { const { accessJwt, refreshJwt } = writeBasicAuthSession(); const session = new BasicAuthSession(accessJwt, refreshJwt); globalThis.fetch.__interceptJson("https://pds.example.com/xrpc/foo", { ok: true, }); const res = await session.fetch("https://pds.example.com/xrpc/foo", { headers: {}, }); assert(res.ok); const authHeader = globalThis.fetch.calls[0].options.headers.Authorization; assert.deepEqual(authHeader, `Bearer ${accessJwt}`); });
it("fetch refreshes the token on 400 ExpiredToken and retries the original request", async () => { const { accessJwt, refreshJwt } = writeBasicAuthSession(); const session = new BasicAuthSession(accessJwt, refreshJwt); const refreshUrl = "https://pds.example.com/xrpc/com.atproto.server.refreshSession"; const newAccessJwt = makeJwt({ aud: "did:web:pds.example.com", sub: "did:plc:test", }); const newRefreshJwt = makeJwt({ sub: "did:plc:test" });
// First call returns 400 ExpiredToken; the retry (after refresh) returns success. let fooCallCount = 0; globalThis.fetch.__intercept( "https://pds.example.com/xrpc/foo", async () => { fooCallCount++; if (fooCallCount === 1) { return { ok: false, status: 400, json: async () => ({ error: "ExpiredToken" }), }; } return { ok: true, status: 200, json: async () => ({ result: "ok" }), headers: { get: () => null }, }; }, ); globalThis.fetch.__interceptJson(refreshUrl, { accessJwt: newAccessJwt, refreshJwt: newRefreshJwt, });
const res = await session.fetch("https://pds.example.com/xrpc/foo", { headers: {}, }); const body = await res.json(); assert.deepEqual(body.result, "ok"); assert.deepEqual(session.accessJwt, newAccessJwt); assert.deepEqual(session.refreshJwt, newRefreshJwt); assert.deepEqual(localStorage.getItem("accessJwt"), newAccessJwt); });
it("fetch does not refresh on a 400 that is not ExpiredToken", async () => { const { accessJwt, refreshJwt } = writeBasicAuthSession(); const session = new BasicAuthSession(accessJwt, refreshJwt); globalThis.fetch.__intercept( "https://pds.example.com/xrpc/foo", async () => ({ ok: false, status: 400, json: async () => ({ error: "InvalidRequest" }), }), ); const res = await session.fetch("https://pds.example.com/xrpc/foo", { headers: {}, }); assert(!res.ok); assert.deepEqual(globalThis.fetch.calls.length, 1); });
it("BasicAuthProvider.logout is a no-op when no session is stored", async () => { const provider = new BasicAuthProvider(); await provider.logout(); assert.deepEqual(await provider.getSession(), null); });
it("BasicAuthProvider does not read localStorage until getSession is called", () => { writeBasicAuthSession(); const provider = new BasicAuthProvider(); assert.deepEqual(provider._loaded, false); assert.deepEqual(provider.session, null); });
it("BasicAuthProvider.getSession lazily loads the session from localStorage", async () => { writeBasicAuthSession({ sub: "did:plc:lazy" }); const provider = new BasicAuthProvider(); const session = await provider.getSession(); assert(session instanceof BasicAuthSession); assert.deepEqual(session.did, "did:plc:lazy"); });
it("fetch throws RefreshTokenError when the refresh request fails", async () => { const { accessJwt, refreshJwt } = writeBasicAuthSession(); const session = new BasicAuthSession(accessJwt, refreshJwt); const refreshUrl = "https://pds.example.com/xrpc/com.atproto.server.refreshSession";
globalThis.fetch.__intercept( "https://pds.example.com/xrpc/foo", async () => ({ ok: false, status: 400, json: async () => ({ error: "ExpiredToken" }), }), ); globalThis.fetch.__intercept(refreshUrl, async () => ({ ok: false, status: 401, }));
let threw = null; try { await session.fetch("https://pds.example.com/xrpc/foo", { headers: {} }); } catch (error) { threw = error; } assert(threw instanceof RefreshTokenError); });});
describe("Auth.requireAuth", () => { let manager; beforeEach(() => { manager = new Auth(new BasicAuthProvider()); });
afterEach(() => { localStorage.clear(); globalThis.window = originalWindow; window.history.replaceState(null, "", originalPath); });
it("returns the session when one exists", async () => { writeBasicAuthSession({ sub: "did:plc:alice" }); manager = new Auth(new BasicAuthProvider()); const session = await manager.requireAuth(); assert(session instanceof BasicAuthSession); assert.deepEqual(session.did, "did:plc:alice"); });
it("redirects to login and never resolves when no session exists", async () => { const capturedHrefs = mockWindowLocation(""); manager.requireAuth(); // don't await — never resolves await Promise.resolve(); await Promise.resolve(); assert(capturedHrefs.length > 0, "expected a redirect to login"); assert(capturedHrefs[0].includes("/login")); });});
describe("Auth.requireNoAuth", () => { let manager; beforeEach(() => { manager = new Auth(new BasicAuthProvider()); });
afterEach(() => { localStorage.clear(); globalThis.window = originalWindow; window.history.replaceState(null, "", originalPath); });
it("returns null when no session exists", async () => { const result = await manager.requireNoAuth(); assert.deepEqual(result, null); });
it("redirects to / when a session exists and no returnTo is set", async () => { writeBasicAuthSession(); manager = new Auth(new BasicAuthProvider()); const capturedHrefs = mockWindowLocation(""); manager.requireNoAuth(); // don't await — never resolves await Promise.resolve(); await Promise.resolve(); assert(capturedHrefs.length > 0, "expected a redirect"); assert.deepEqual(capturedHrefs[0], "/"); });
it("redirects to returnTo when a session exists and returnTo is a valid path", async () => { writeBasicAuthSession(); manager = new Auth(new BasicAuthProvider()); const capturedHrefs = mockWindowLocation("?returnTo=%2Ffeed"); manager.requireNoAuth(); await Promise.resolve(); await Promise.resolve(); assert.deepEqual(capturedHrefs[0], "/feed"); });
it("does not redirect when addAccount=1 is set, even if a session exists", async () => { writeBasicAuthSession(); manager = new Auth(new BasicAuthProvider()); const capturedHrefs = mockWindowLocation( "?addAccount=1&returnTo=%2Fsettings", ); const result = await manager.requireNoAuth(); assert.deepEqual(result, null); assert.deepEqual(capturedHrefs.length, 0); });
it("falls back to / when returnTo is an external URL", async () => { writeBasicAuthSession(); manager = new Auth(new BasicAuthProvider()); const capturedHrefs = mockWindowLocation( "?returnTo=https%3A%2F%2Fevil.com", ); manager.requireNoAuth(); await Promise.resolve(); await Promise.resolve(); assert.deepEqual(capturedHrefs[0], "/"); });});
describe("Auth account management", () => { afterEach(() => { globalThis.window = originalWindow; window.history.replaceState(null, "", originalPath); });
async function flushMicrotasks(count = 10) { for (let i = 0; i < count; i++) await Promise.resolve(); }
function makeMultiAccountProvider({ accounts, currentDid }) { return { supportsMultipleAccounts: () => true, listAccounts: mock.fn(() => Promise.resolve(accounts)), getSession: mock.fn(() => Promise.resolve(currentDid ? { did: currentDid } : null), ), switchToAccount: mock.fn((did) => { currentDid = did; return Promise.resolve(); }), removeAccount: mock.fn((did) => { accounts = accounts.filter((account) => account.did !== did); if (currentDid === did) currentDid = accounts[0]?.did ?? null; return Promise.resolve(); }), logout: mock.fn(() => Promise.resolve()), }; }
it("listAccounts delegates to the provider", async () => { const provider = makeMultiAccountProvider({ accounts: [ { did: "did:plc:alice", handle: "alice.test" }, { did: "did:plc:bob", handle: "bob.test" }, ], currentDid: "did:plc:alice", }); const manager = new Auth(provider); const accounts = await manager.listAccounts(); assert.deepEqual(accounts.length, 2); assert.deepEqual(provider.listAccounts.mock.callCount(), 1); });
it("listAccounts flips needsReauth on accounts whose stored scope is stale", async () => { globalThis.window = { ...originalWindow, env: { oauthScopes: "atproto rpc:a rpc:b" }, }; const provider = makeMultiAccountProvider({ accounts: [ { did: "did:plc:alice", handle: "alice.test", scope: "atproto rpc:a", needsReauth: false, }, { did: "did:plc:bob", handle: "bob.test", scope: "atproto rpc:a rpc:b", needsReauth: false, }, { did: "did:plc:carol", handle: "carol.test", scope: null, needsReauth: true, }, ], currentDid: "did:plc:alice", }); const manager = new Auth(provider); const accounts = await manager.listAccounts(); const byDid = Object.fromEntries( accounts.map((entry) => [entry.did, entry]), ); assert.deepEqual(byDid["did:plc:alice"].needsReauth, true); assert.deepEqual(byDid["did:plc:bob"].needsReauth, false); assert.deepEqual(byDid["did:plc:carol"].needsReauth, true); });
it("listAccounts does not flip needsReauth when only optional scopes are missing", async () => { globalThis.window = { ...originalWindow, env: { oauthScopes: "atproto rpc:a rpc:future", oauthOptionalScopes: "rpc:future", }, }; const provider = makeMultiAccountProvider({ accounts: [ { did: "did:plc:alice", handle: "alice.test", scope: "atproto rpc:a", needsReauth: false, }, ], currentDid: "did:plc:alice", }); const manager = new Auth(provider); const accounts = await manager.listAccounts(); assert.deepEqual(accounts[0].needsReauth, false); });
it("listAccounts leaves needsReauth alone for providers that don't expose scope", async () => { const provider = makeMultiAccountProvider({ accounts: [ { did: "did:plc:alice", handle: "alice.test", needsReauth: false }, ], currentDid: "did:plc:alice", }); const manager = new Auth(provider); const accounts = await manager.listAccounts(); assert.deepEqual(accounts[0].needsReauth, false); });
it("supportsMultipleAccounts reflects the provider capability", () => { const multi = new Auth(makeMultiAccountProvider({ accounts: [] })); assert.deepEqual(multi.supportsMultipleAccounts(), true); const basic = new Auth(new BasicAuthProvider()); assert.deepEqual(basic.supportsMultipleAccounts(), false); });
it("switchAccount flips the provider and redirects to /", async () => { const capturedHrefs = mockWindowLocation(""); const provider = makeMultiAccountProvider({ accounts: [ { did: "did:plc:alice", handle: "alice.test" }, { did: "did:plc:bob", handle: "bob.test" }, ], currentDid: "did:plc:alice", }); const manager = new Auth(provider); manager.switchAccount("did:plc:bob"); // never resolves await flushMicrotasks(); assert.deepEqual(provider.switchToAccount.mock.callCount(), 1); assert.deepEqual( provider.switchToAccount.mock.calls[0].arguments[0], "did:plc:bob", ); assert.deepEqual(capturedHrefs.at(-1), "reload"); });
it("switchAccount throws when the provider does not support it", async () => { const manager = new Auth(new BasicAuthProvider()); let threw = null; try { await manager.switchAccount("did:plc:bob"); } catch (error) { threw = error; } assert(threw !== null); });
it("removeAccount drops the account in place when it is not current", async () => { const provider = makeMultiAccountProvider({ accounts: [ { did: "did:plc:alice", handle: "alice.test" }, { did: "did:plc:bob", handle: "bob.test" }, ], currentDid: "did:plc:alice", }); const manager = new Auth(provider); await manager.removeAccount("did:plc:bob"); assert.deepEqual(provider.removeAccount.mock.callCount(), 1); assert.deepEqual( provider.removeAccount.mock.calls[0].arguments[0], "did:plc:bob", ); assert.deepEqual(provider.switchToAccount.mock.callCount(), 0); });
it("removeAccount switches to another account first when removing the current one", async () => { const capturedHrefs = mockWindowLocation(""); const provider = makeMultiAccountProvider({ accounts: [ { did: "did:plc:alice", handle: "alice.test" }, { did: "did:plc:bob", handle: "bob.test" }, ], currentDid: "did:plc:alice", }); const manager = new Auth(provider); manager.removeAccount("did:plc:alice"); // never resolves await flushMicrotasks(); assert.deepEqual(provider.switchToAccount.mock.callCount(), 1); assert.deepEqual( provider.switchToAccount.mock.calls[0].arguments[0], "did:plc:bob", ); assert.deepEqual(provider.removeAccount.mock.callCount(), 1); assert.deepEqual( provider.removeAccount.mock.calls[0].arguments[0], "did:plc:alice", ); assert.deepEqual(capturedHrefs.at(-1), "reload"); });
it("removeAccount redirects to login when removing the only account", async () => { const capturedHrefs = mockWindowLocation(""); const provider = makeMultiAccountProvider({ accounts: [{ did: "did:plc:alice", handle: "alice.test" }], currentDid: "did:plc:alice", }); const manager = new Auth(provider); manager.removeAccount("did:plc:alice"); // never resolves await flushMicrotasks(); assert.deepEqual(provider.switchToAccount.mock.callCount(), 0); assert.deepEqual(provider.removeAccount.mock.callCount(), 1); assert(capturedHrefs.at(-1).includes("/login")); });});
describe("Auth.login", () => { const originalSetTimeout = globalThis.setTimeout; beforeEach(() => { globalThis.setTimeout = (fn) => originalSetTimeout(fn, 0); }); afterEach(() => { globalThis.setTimeout = originalSetTimeout; });
it("delegates to provider.login with the args object intact", async () => { const provider = { login: mock.fn(() => Promise.resolve("session")), }; const manager = new Auth(provider); const args = { handle: "alice.test", returnTo: "/feed" }; const result = await manager.login(args); assert.deepEqual(result, "session"); assert.deepEqual(provider.login.mock.callCount(), 1); assert.deepEqual(provider.login.mock.calls[0].arguments[0], args); });
it("throws TimeoutError when provider.login hangs past the timeout", async () => { const provider = { login: mock.fn(() => new Promise(() => {})), }; const manager = new Auth(provider); let threw = null; try { await manager.login({ handle: "alice.test" }); } catch (error) { threw = error; } assert(threw instanceof TimeoutError); });});
describe("Auth.logout", () => { it("delegates to provider.logout with the did", async () => { const provider = makeMockProvider(); const manager = new Auth(provider); await manager.logout("did:plc:alice"); assert.deepEqual(provider.logout.mock.callCount(), 1); assert.deepEqual( provider.logout.mock.calls[0].arguments[0], "did:plc:alice", ); });
it("delegates to provider.logout with no did when called without args", async () => { const provider = makeMockProvider(); const manager = new Auth(provider); await manager.logout(); assert.deepEqual(provider.logout.mock.callCount(), 1); assert.deepEqual(provider.logout.mock.calls[0].arguments[0], null); });});
describe("getMissingScopes", () => { it("returns an empty array when granted matches required", () => { const result = getMissingScopes( "atproto rpc:a rpc:b", "atproto rpc:a rpc:b", ); assert.deepEqual(result.length, 0); });
it("returns scopes present in required but missing from granted", () => { const result = getMissingScopes("atproto rpc:a", "atproto rpc:a rpc:b"); assert.deepEqual(result, ["rpc:b"]); });
it("ignores extra scopes in granted that are not required", () => { const result = getMissingScopes("atproto rpc:a rpc:extra", "atproto rpc:a"); assert.deepEqual(result.length, 0); });
it("tolerates extra whitespace and empty tokens", () => { const result = getMissingScopes(" atproto rpc:a ", "atproto rpc:a"); assert.deepEqual(result.length, 0); });
it("treats scopes with different query params as distinct", () => { // Exact-string match: ?aud=* and ?aud=did:web:foo are not equivalent. const result = getMissingScopes("rpc:a?aud=did:web:foo", "rpc:a?aud=*"); assert.deepEqual(result, ["rpc:a?aud=*"]); });
it("does not report missing scopes that are optional", () => { const result = getMissingScopes( "atproto rpc:a", "atproto rpc:a rpc:future", "rpc:future", ); assert.deepEqual(result.length, 0); });
it("still reports missing required scopes alongside missing optional ones", () => { const result = getMissingScopes( "atproto rpc:a", "atproto rpc:a rpc:b rpc:future", "rpc:future", ); assert.deepEqual(result, ["rpc:b"]); });
it("optional scopes that are granted change nothing", () => { const result = getMissingScopes( "atproto rpc:a rpc:future", "atproto rpc:a rpc:future", "rpc:future", ); assert.deepEqual(result.length, 0); });});
describe("Auth.hasScope", () => { it("returns false when there is no session", async () => { const manager = new Auth(makeMockProvider()); assert.deepEqual(await manager.hasScope("rpc:a"), false); });
it("returns true when the session has no scope string (BasicAuth)", async () => { const provider = makeMockProvider(); provider.getSession = mock.fn(() => Promise.resolve({ scope: undefined })); const manager = new Auth(provider); assert.deepEqual(await manager.hasScope("rpc:a"), true); });
it("returns true when the scope is granted exactly", async () => { const provider = makeMockProvider(); provider.getSession = mock.fn(() => Promise.resolve({ scope: "atproto rpc:a rpc:b" }), ); const manager = new Auth(provider); assert.deepEqual(await manager.hasScope("rpc:a"), true); });
it("returns true when a param variant of the scope is granted", async () => { const provider = makeMockProvider(); provider.getSession = mock.fn(() => Promise.resolve({ scope: "atproto rpc:a?aud=*" }), ); const manager = new Auth(provider); assert.deepEqual(await manager.hasScope("rpc:a"), true); });
it("returns false when the scope is not granted", async () => { const provider = makeMockProvider(); provider.getSession = mock.fn(() => Promise.resolve({ scope: "atproto rpc:a?aud=*" }), ); const manager = new Auth(provider); assert.deepEqual(await manager.hasScope("rpc:b"), false); });
it("does not treat a scope as a prefix of a longer scope name", async () => { const provider = makeMockProvider(); provider.getSession = mock.fn(() => Promise.resolve({ scope: "rpc:abc?aud=*" }), ); const manager = new Auth(provider); assert.deepEqual(await manager.hasScope("rpc:a"), false); });});
describe("Auth.ensureCurrentScopes", () => { const originalEnv = globalThis.window.env;
afterEach(() => { globalThis.window = originalWindow; globalThis.window.env = originalEnv; });
it("does nothing when there is no session", async () => { const capturedHrefs = mockWindowLocation(); globalThis.window.env = { oauthScopes: "atproto rpc:a" }; const provider = makeMockProvider(); const manager = new Auth(provider); await manager.ensureCurrentScopes(); assert.deepEqual(capturedHrefs.length, 0); assert.deepEqual(provider.logout.mock.callCount(), 0); });
it("does nothing when the session has no scope (BasicAuth)", async () => { const capturedHrefs = mockWindowLocation(); globalThis.window.env = { oauthScopes: "atproto rpc:a" }; const provider = makeMockProvider(); provider.getSession = mock.fn(() => Promise.resolve({ scope: undefined })); const manager = new Auth(provider); await manager.ensureCurrentScopes(); assert.deepEqual(capturedHrefs.length, 0); assert.deepEqual(provider.logout.mock.callCount(), 0); });
it("does nothing when granted scopes match required", async () => { const capturedHrefs = mockWindowLocation(); globalThis.window.env = { oauthScopes: "atproto rpc:a rpc:b" }; const provider = makeMockProvider(); provider.getSession = mock.fn(() => Promise.resolve({ scope: "atproto rpc:a rpc:b" }), ); const manager = new Auth(provider); await manager.ensureCurrentScopes(); assert.deepEqual(capturedHrefs.length, 0); assert.deepEqual(provider.logout.mock.callCount(), 0); });
it("does nothing when only optional scopes are missing", async () => { const capturedHrefs = mockWindowLocation(); globalThis.window.env = { oauthScopes: "atproto rpc:a rpc:future", oauthOptionalScopes: "rpc:future", }; const provider = makeMockProvider(); provider.getSession = mock.fn(() => Promise.resolve({ scope: "atproto rpc:a" }), ); const manager = new Auth(provider); await manager.ensureCurrentScopes(); assert.deepEqual(capturedHrefs.length, 0); assert.deepEqual(provider.logout.mock.callCount(), 0); });
it("logs out and redirects to login when a required scope is missing", async () => { const capturedHrefs = mockWindowLocation(); globalThis.window.env = { oauthScopes: "atproto rpc:a rpc:b" }; const provider = makeMockProvider(); provider.getSession = mock.fn(() => Promise.resolve({ scope: "atproto rpc:a" }), ); const manager = new Auth(provider); manager.ensureCurrentScopes(); await Promise.resolve(); await Promise.resolve(); assert.deepEqual(provider.logout.mock.callCount(), 1); assert(capturedHrefs.length > 0, "expected a redirect"); assert(capturedHrefs[0].includes("/login")); });
it("still redirects to login when provider.logout throws", async () => { const capturedHrefs = mockWindowLocation(); globalThis.window.env = { oauthScopes: "atproto rpc:a rpc:b" }; const provider = makeMockProvider({ logoutFn: () => Promise.reject(new Error("logout failed")), }); provider.getSession = mock.fn(() => Promise.resolve({ scope: "atproto rpc:a" }), ); const manager = new Auth(provider); manager.ensureCurrentScopes(); await Promise.resolve(); await Promise.resolve(); await Promise.resolve(); assert( capturedHrefs.length > 0, "expected a redirect despite logout error", ); assert(capturedHrefs[0].includes("/login")); });});