diff --git a/src/cli.mjs b/src/cli.mjs index fcb407c7..f770a075 100644 --- a/src/cli.mjs +++ b/src/cli.mjs @@ -13,21 +13,19 @@ import loginCommand from "./commands/login.mjs"; import queryCommand from "./commands/query.mjs"; import schemaCommand from "./commands/schema/schema.mjs"; import shellCommand from "./commands/shell.mjs"; +import { container, setContainer } from "./config/container.mjs"; import { buildCredentials } from "./lib/auth/credentials.mjs"; import { getDbCompletions, getProfileCompletions } from "./lib/completions.mjs"; import { configParser } from "./lib/config/config.mjs"; import { handleParseYargsError } from "./lib/errors.mjs"; import { + applyAccountUrl, applyLocalArg, checkForUpdates, fixPaths, logArgv, } from "./lib/middleware.mjs"; -/** @typedef {import('awilix').AwilixContainer } cliContainer */ - -/** @type {cliContainer} */ -export let container; /** @type {import('yargs').Argv} */ export let builtYargs; @@ -36,10 +34,11 @@ const __dirname = path.dirname(__filename); /** * @param {string|string[]} _argvInput - The command string provided by the user or test. Parsed by yargs into an argv object. - * @param {cliContainer} _container - A built and ready for use awilix container with registered injectables. + * @param {import('./config/container.mjs').container} container - A built and ready for use awilix container with registered injectables. */ -export async function run(_argvInput, _container) { - container = _container; +export async function run(_argvInput, container) { + setContainer(container); + const argvInput = _argvInput; const logger = container.resolve("logger"); const parseYargs = container.resolve("parseYargs"); @@ -111,7 +110,10 @@ function buildYargs(argvInput) { .env("FAUNA") .config("config", configParser.bind(null, argvInput)) .middleware([checkForUpdates, logArgv], true) - .middleware([applyLocalArg, fixPaths, buildCredentials], false) + .middleware( + [applyLocalArg, fixPaths, applyAccountUrl, buildCredentials], + false, + ) .command(queryCommand) .command(shellCommand) .command(loginCommand) diff --git a/src/commands/database/create.mjs b/src/commands/database/create.mjs index 5bf5ed05..0f97c04e 100644 --- a/src/commands/database/create.mjs +++ b/src/commands/database/create.mjs @@ -1,7 +1,7 @@ //@ts-check import { ServiceError } from "fauna"; -import { container } from "../../cli.mjs"; +import { container } from "../../config/container.mjs"; import { CommandError } from "../../lib/errors.mjs"; import { faunaToCommandError } from "../../lib/fauna.mjs"; import { getSecret, retryInvalidCredsOnce } from "../../lib/fauna-client.mjs"; diff --git a/src/commands/database/delete.mjs b/src/commands/database/delete.mjs index 4b491bef..b98d7dca 100644 --- a/src/commands/database/delete.mjs +++ b/src/commands/database/delete.mjs @@ -2,7 +2,7 @@ import { ServiceError } from "fauna"; -import { container } from "../../cli.mjs"; +import { container } from "../../config/container.mjs"; import { CommandError } from "../../lib/errors.mjs"; import { faunaToCommandError } from "../../lib/fauna.mjs"; import { getSecret, retryInvalidCredsOnce } from "../../lib/fauna-client.mjs"; diff --git a/src/commands/database/list.mjs b/src/commands/database/list.mjs index ecef0f46..e82a9113 100644 --- a/src/commands/database/list.mjs +++ b/src/commands/database/list.mjs @@ -1,15 +1,14 @@ //@ts-check import chalk from "chalk"; -import { container } from "../../cli.mjs"; +import { container } from "../../config/container.mjs"; import { faunaToCommandError } from "../../lib/fauna.mjs"; -import { FaunaAccountClient } from "../../lib/fauna-account-client.mjs"; import { colorize, Format } from "../../lib/formatting/colorize.mjs"; async function listDatabasesWithAccountAPI(argv) { const { pageSize, database } = argv; - const accountClient = new FaunaAccountClient(); - const response = await accountClient.listDatabases({ + const { listDatabases } = container.resolve("accountAPI"); + const response = await listDatabases({ pageSize, path: database, }); diff --git a/src/commands/local.mjs b/src/commands/local.mjs index e2acb988..4c1e331b 100644 --- a/src/commands/local.mjs +++ b/src/commands/local.mjs @@ -1,8 +1,8 @@ import chalk from "chalk"; import { AbortError } from "fauna"; -import { container } from "../cli.mjs"; import { pushSchema } from "../commands/schema/push.mjs"; +import { container } from "../config/container.mjs"; import { ensureContainerRunning } from "../lib/docker-containers.mjs"; import { CommandError, ValidationError } from "../lib/errors.mjs"; import { colorize, Format } from "../lib/formatting/colorize.mjs"; diff --git a/src/commands/login.mjs b/src/commands/login.mjs index 0d89b60c..45757738 100644 --- a/src/commands/login.mjs +++ b/src/commands/login.mjs @@ -1,7 +1,7 @@ //@ts-check -import { container } from "../cli.mjs"; -import { FaunaAccountClient } from "../lib/fauna-account-client.mjs"; +import { container } from "../config/container.mjs"; +import { getToken, startOAuthRequest } from "../lib/account-api.mjs"; async function doLogin(argv) { const logger = container.resolve("logger"); @@ -13,16 +13,29 @@ async function doLogin(argv) { const credentials = container.resolve("credentials"); const oAuth = container.resolve("oauthClient"); oAuth.server.on("ready", async () => { - const authCodeParams = oAuth.getOAuthParams(); - const dashboardOAuthURL = - await FaunaAccountClient.startOAuthRequest(authCodeParams); + const authCodeParams = oAuth.getOAuthParams({ clientId: argv.clientId }); + const dashboardOAuthURL = await startOAuthRequest(authCodeParams); open(dashboardOAuthURL); logger.stdout(`To login, open your browser to:\n${dashboardOAuthURL}`); }); oAuth.server.on("auth_code_received", async () => { try { - const tokenParams = oAuth.getTokenParams(); - const accessToken = await FaunaAccountClient.getToken(tokenParams); + const { clientId, clientSecret, authCode, redirectURI, codeVerifier } = + oAuth.getTokenParams({ + clientId: argv.clientId, + clientSecret: argv.clientSecret, + }); + + /* eslint-disable camelcase */ + const accessToken = await getToken({ + client_id: clientId, + client_secret: clientSecret, + code: authCode, + redirect_uri: redirectURI, + code_verifier: codeVerifier, + }); + /* eslint-enable camelcase */ + await credentials.login(accessToken); } catch (err) { logger.stderr(err); diff --git a/src/commands/query.mjs b/src/commands/query.mjs index cde0fd56..ed74e28e 100644 --- a/src/commands/query.mjs +++ b/src/commands/query.mjs @@ -1,6 +1,6 @@ //@ts-check -import { container } from "../cli.mjs"; +import { container } from "../config/container.mjs"; import { CommandError, isUnknownError, diff --git a/src/commands/schema/abandon.mjs b/src/commands/schema/abandon.mjs index 5ded505c..80c200e9 100644 --- a/src/commands/schema/abandon.mjs +++ b/src/commands/schema/abandon.mjs @@ -1,6 +1,6 @@ //@ts-check -import { container } from "../../cli.mjs"; +import { container } from "../../config/container.mjs"; import { CommandError } from "../../lib/errors.mjs"; import { getSecret } from "../../lib/fauna-client.mjs"; diff --git a/src/commands/schema/commit.mjs b/src/commands/schema/commit.mjs index 7d26bb41..75b22a04 100644 --- a/src/commands/schema/commit.mjs +++ b/src/commands/schema/commit.mjs @@ -1,6 +1,6 @@ //@ts-check -import { container } from "../../cli.mjs"; +import { container } from "../../config/container.mjs"; import { CommandError } from "../../lib/errors.mjs"; import { getSecret } from "../../lib/fauna-client.mjs"; diff --git a/src/commands/schema/diff.mjs b/src/commands/schema/diff.mjs index 2177ea31..9bbe1213 100644 --- a/src/commands/schema/diff.mjs +++ b/src/commands/schema/diff.mjs @@ -2,7 +2,7 @@ import chalk from "chalk"; -import { container } from "../../cli.mjs"; +import { container } from "../../config/container.mjs"; import { ValidationError } from "../../lib/errors.mjs"; import { getSecret } from "../../lib/fauna-client.mjs"; import { reformatFSL } from "../../lib/schema.mjs"; diff --git a/src/commands/schema/pull.mjs b/src/commands/schema/pull.mjs index 649df955..7f69abcd 100644 --- a/src/commands/schema/pull.mjs +++ b/src/commands/schema/pull.mjs @@ -1,6 +1,6 @@ //@ts-check -import { container } from "../../cli.mjs"; +import { container } from "../../config/container.mjs"; import { getSecret } from "../../lib/fauna-client.mjs"; import { LOCAL_SCHEMA_OPTIONS } from "./schema.mjs"; diff --git a/src/commands/schema/push.mjs b/src/commands/schema/push.mjs index a43f9609..70fe2b40 100644 --- a/src/commands/schema/push.mjs +++ b/src/commands/schema/push.mjs @@ -2,7 +2,7 @@ import path from "path"; -import { container } from "../../cli.mjs"; +import { container } from "../../config/container.mjs"; import { ValidationError } from "../../lib/errors.mjs"; import { getSecret } from "../../lib/fauna-client.mjs"; import { reformatFSL } from "../../lib/schema.mjs"; diff --git a/src/commands/schema/status.mjs b/src/commands/schema/status.mjs index 5a2aa6ed..daa3dd77 100644 --- a/src/commands/schema/status.mjs +++ b/src/commands/schema/status.mjs @@ -3,7 +3,7 @@ import chalk from "chalk"; import path from "path"; -import { container } from "../../cli.mjs"; +import { container } from "../../config/container.mjs"; import { CommandError } from "../../lib/errors.mjs"; import { getSecret } from "../../lib/fauna-client.mjs"; import { reformatFSL } from "../../lib/schema.mjs"; diff --git a/src/commands/shell.mjs b/src/commands/shell.mjs index 3e765b00..d51e24a7 100644 --- a/src/commands/shell.mjs +++ b/src/commands/shell.mjs @@ -5,7 +5,7 @@ import repl from "node:repl"; import * as esprima from "esprima"; -import { container } from "../cli.mjs"; +import { container } from "../config/container.mjs"; import { formatQueryResponse, getSecret } from "../lib/fauna-client.mjs"; import { clearHistoryStorage, initHistoryStorage } from "../lib/file-util.mjs"; import { validateDatabaseOrSecret } from "../lib/middleware.mjs"; diff --git a/src/config/container.mjs b/src/config/container.mjs new file mode 100644 index 00000000..1c8a2278 --- /dev/null +++ b/src/config/container.mjs @@ -0,0 +1,10 @@ +/** @typedef {import('awilix').AwilixContainer } container */ +/** @type {container} */ +export let container; + +/** + * @param {container} newContainer - The new container to set. + */ +export const setContainer = (newContainer) => { + container = newContainer; +}; diff --git a/src/config/setup-container.mjs b/src/config/setup-container.mjs index 600ffffe..d318529e 100644 --- a/src/config/setup-container.mjs +++ b/src/config/setup-container.mjs @@ -15,10 +15,10 @@ import open from "open"; import updateNotifier from "update-notifier"; import { parseYargs } from "../cli.mjs"; -import { makeAccountRequest } from "../lib/account.mjs"; +import accountAPI from "../lib/account-api.mjs"; import { Credentials } from "../lib/auth/credentials.mjs"; import OAuthClient from "../lib/auth/oauth-client.mjs"; -import { makeRetryableFaunaRequest } from "../lib/db.mjs"; +import { makeRetryableFaunaRequest } from "../lib/core-api.mjs"; import * as faunaV10 from "../lib/fauna.mjs"; import { formatError, @@ -73,7 +73,6 @@ export const injectables = { updateNotifier: awilix.asValue(updateNotifier), fauna: awilix.asValue(fauna), faunadb: awilix.asValue(faunadb), - codeToAnsi: awilix.asValue(codeToAnsi), // generic lib (homemade utilities) parseYargs: awilix.asValue(parseYargs), @@ -86,8 +85,9 @@ export const injectables = { }, { lifetime: Lifetime.SINGLETON }, ), + codeToAnsi: awilix.asValue(codeToAnsi), oauthClient: awilix.asClass(OAuthClient, { lifetime: Lifetime.SCOPED }), - makeAccountRequest: awilix.asValue(makeAccountRequest), + accountAPI: awilix.asValue(accountAPI), makeFaunaRequest: awilix.asValue(makeRetryableFaunaRequest), errorHandler: awilix.asValue((_error, exitCode) => exit(exitCode)), @@ -96,6 +96,7 @@ export const injectables = { credentials: awilix.asClass(Credentials, { lifetime: Lifetime.SINGLETON, }), + // utilities for interacting with Fauna runQueryFromString: awilix.asValue(runQueryFromString), formatError: awilix.asValue(formatError), diff --git a/src/config/setup-test-container.mjs b/src/config/setup-test-container.mjs index da551119..c5ce80f3 100644 --- a/src/config/setup-test-container.mjs +++ b/src/config/setup-test-container.mjs @@ -9,7 +9,7 @@ import { spy, stub } from "sinon"; import { f, InMemoryWritableStream } from "../../test/helpers.mjs"; import { parseYargs } from "../cli.mjs"; -import { makeRetryableFaunaRequest } from "../lib/db.mjs"; +import { makeRetryableFaunaRequest } from "../lib/core-api.mjs"; import * as faunaClientV10 from "../lib/fauna.mjs"; import { formatQueryInfo } from "../lib/fauna-client.mjs"; import * as faunaClientV4 from "../lib/faunadb.mjs"; @@ -79,11 +79,6 @@ export function setupTestContainer() { }), codeToAnsi: awilix.asValue(stub().returnsArg(0)), logger: awilix.asFunction((cradle) => spy(buildLogger(cradle))).singleton(), - AccountClient: awilix.asValue(() => ({ - startOAuthRequest: stub(), - getToken: stub(), - getSession: stub(), - })), oauthClient: awilix.asFunction(stub()), docker: awilix.asValue({ createContainer: stub(), @@ -96,6 +91,12 @@ export function setupTestContainer() { pull: stub(), }), credentials: awilix.asClass(stub()).singleton(), + accountAPI: awilix.asValue({ + listDatabases: stub(), + createKey: stub(), + refreshSession: stub(), + getSession: stub(), + }), errorHandler: awilix.asValue((error, exitCode) => { error.code = exitCode; throw error; @@ -104,7 +105,6 @@ export function setupTestContainer() { fetch: awilix.asValue(stub().resolves(f({}))), gatherFSL: awilix.asValue(stub().resolves([])), makeFaunaRequest: awilix.asValue(spy(makeRetryableFaunaRequest)), - makeAccountRequest: awilix.asValue(stub()), runQueryFromString: awilix.asValue(stub().resolves({})), isQueryable: awilix.asValue(stub().resolves()), formatError: awilix.asValue(stub()), diff --git a/src/lib/account-api.mjs b/src/lib/account-api.mjs new file mode 100644 index 00000000..1b76f211 --- /dev/null +++ b/src/lib/account-api.mjs @@ -0,0 +1,381 @@ +//@ts-check + +import { container } from "../config/container.mjs"; +import { + AuthenticationError, + AuthorizationError, + CommandError, +} from "./errors.mjs"; +import { standardizeRegion } from "./utils.mjs"; + +const API_VERSIONS = { + v1: "/api/v1", + v2: "/v2", +}; + +let accountUrl = process.env.FAUNA_ACCOUNT_URL ?? "https://account.fauna.com"; + +/** + * References the account URL set in the account-api module. + * @returns {string} The account URL + */ +export function getAccountUrl() { + return accountUrl; +} + +/** + * Sets the account URL for the account-api module. + * @param {string} url - The account URL to set + */ +export function setAccountUrl(url) { + accountUrl = url; +} + +/** + * Builds a URL for the account API + * + * @param {Object} opts + * @param {string} opts.endpoint - The endpoint to append to the account URL + * @param {Object} [opts.params] - The query parameters to append to the URL + * @param {string} [opts.version] - The API version to use, defaults to v1 + * @returns {URL} The constructed URL + */ +export function toResource({ + endpoint, + params = {}, + version = API_VERSIONS.v1, +}) { + const url = new URL(`${version}${endpoint}`, getAccountUrl()); + for (const [key, value] of Object.entries(params)) { + url.searchParams.set(key, value); + } + + return url; +} + +/** + * Fetches the account API using an account key with a retry mechanism for 401s. + * No secret needs to be provided, as the account key is refreshed as needed. + * + * @param {string | URL} url - The URL to fetch + * @param {Object} options - The request options + * @returns {Promise} The response from the account API + */ +export async function fetchWithAccountKey(url, options) { + const logger = container.resolve("logger"); + const fetch = container.resolve("fetch"); + const accountKeys = container.resolve("credentials").accountKeys; + + let response = await fetch(url, { + ...options, + headers: { ...options.headers, Authorization: `Bearer ${accountKeys.key}` }, + }); + + if (response.status !== 401) { + return response; + } + + logger.debug("Retryable 401 error, attempting to refresh session", "creds"); + + await accountKeys.onInvalidCreds(); + + response = await fetch(url, { + ...options, + headers: { ...options.headers, Authorization: `Bearer ${accountKeys.key}` }, + }); + + if (response.status === 401) { + logger.debug( + "Failed to refresh session, expired or missing refresh token", + "creds", + ); + accountKeys.promptLogin(); + } + + return response; +} + +/** + * Parses an error response from the account API. v1 endpoints return code and reason values + * directly in the body. v2 endpoints return an error object with code and message properties. + * + * @param {Object} body - The JSON body of the response + * @returns {Object} - The error code and message + */ +export const parseErrorResponse = (body) => { + let { code, message, metadata } = { + code: "unknown_error", + message: + "The Account API responded with an error, but no error details were provided.", + metadata: {}, + }; + + if (!body) { + return { code, message, metadata }; + } + + // v2 endpoints return an error object with code, message, and metadata properties + if (body.error) { + ({ code, message, metadata } = body.error); + } else { + // v1 endpoints return code and reason values directly in the body + ({ code, reason: message } = body); + } + + return { code, message, metadata }; +}; + +/** + * Throws an error based on the status code of the response + * + * @param {Response} response + * @throws {AuthenticationError | AuthorizationError | CommandError | Error} + */ +export async function accountToCommandError(response) { + let { code, message, metadata, body } = {}; + + try { + body = await response.json(); + ({ message, code, metadata } = parseErrorResponse(body)); + } catch (e) { + code = "unknown_error"; + message = + "An unknown error occurred while making a request to the Account API."; + } + + // If consumers want to do more with this, they analyze the cause + const responseAsCause = Object.assign(new Error(message), { + status: response.status, + body, + headers: response.headers, + code, + message, + metadata, + }); + + switch (response.status) { + case 401: + throw new AuthenticationError({ cause: responseAsCause }); + case 403: + throw new AuthorizationError({ cause: responseAsCause }); + case 400: + case 404: + throw new CommandError(message, { + cause: responseAsCause, + hideHelp: true, + }); + default: + // @ts-ignore + throw new Error(message, { cause: responseAsCause }); + } +} + +/** + * Handles the response from the account API. + * @param {Response} response - The response from the account API + * @returns {Promise} The JSON body of the response + * @throws {AuthorizationError | AuthenticationError | CommandError | Error} If the response is not OK + */ +export async function responseHandler(response) { + if (!response.ok) { + await accountToCommandError(response); + } + + return await response.json(); +} + +/** + * Starts an OAuth request. + * @param {Object} params - The parameters for the OAuth request + * @param {string} params.client_id - The client ID + * @param {string} params.redirect_uri - The redirect URI + * @param {string} params.code_challenge - The code challenge + * @param {string} params.code_challenge_method - The code challenge method + * @param {string} params.response_type - The response type + * @param {string} params.scope - The scope + * @param {string} params.state - The state + * @returns {Promise} The URL to redirect the user to + * @throws {Error} If the response is not OK + */ +export async function startOAuthRequest(params) { + const fetch = container.resolve("fetch"); + const url = toResource({ endpoint: "/oauth/authorize", params }); + + const response = await fetch(url, { + method: "GET", + headers: { + "content-type": "text/html", + }, + redirect: "manual", + }); + + if (response.status !== 302) { + throw new Error( + `Failed to start OAuth request: ${response.status} - ${response.statusText}`, + ); + } + + const dashboardOAuthURL = response.headers.get("location"); + if (!dashboardOAuthURL) { + throw new Error("No location header found in response"); + } + + const error = new URL(dashboardOAuthURL).searchParams.get("error"); + if (error) { + throw new Error(`Error during login: ${error}`); + } + + return dashboardOAuthURL; +} + +/** + * Gets an access token from the account API. + * @param {Object} params - The parameters for the access token request + * @param {string} params.client_id - The client ID + * @param {string} params.client_secret - The client secret + * @param {string} params.code - The authorization code + * @param {string} params.redirect_uri - The redirect URI + * @param {string} params.code_verifier - The code verifier + * @returns {Promise} The access token + */ +export async function getToken(params) { + const fetch = container.resolve("fetch"); + const url = toResource({ endpoint: "/oauth/token" }); + const body = new URLSearchParams({ + ...params, + grant_type: "authorization_code", // eslint-disable-line camelcase + }); + + const response = await fetch(url, { + method: "POST", + headers: { + "content-type": "application/x-www-form-urlencoded", + }, + body, + }); + + if (!response.ok) { + throw new AuthorizationError( + `Unable to get token while authorizing with Fauna`, + { cause: response }, + ); + } + + const { access_token: accessToken } = await response.json(); + + return accessToken; +} + +/** + * Gets a session from the account API. + * @param {string} accessToken - The access token + * @returns {Promise} The session + * @throws {AuthorizationError | AuthenticationError | CommandError | Error} If the response is not OK + */ +async function getSession(accessToken) { + const fetch = container.resolve("fetch"); + const url = toResource({ endpoint: "/session" }); + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${accessToken}`, + }, + }); + + const { account_key: accountKey, refresh_token: refreshToken } = + await responseHandler(response); + + return { accountKey, refreshToken }; +} + +/** + * Refreshes a session from the account API. + * @param {string} refreshToken - The refresh token + * @returns {Promise} The session + * @throws {AuthorizationError | AuthenticationError | CommandError | Error} If the response is not OK + */ +async function refreshSession(refreshToken) { + const fetch = container.resolve("fetch"); + const url = toResource({ endpoint: "/session/refresh" }); + + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${refreshToken}`, + }, + }); + + const { account_key: newAccountKey, refresh_token: newRefreshToken } = + await responseHandler(response); + + return { accountKey: newAccountKey, refreshToken: newRefreshToken }; +} + +/** + * List all databases for the current account. + * + * @param {Object} [params] - The parameters for listing databases. + * @param {string} [params.path] - The path of the database, including region group + * @param {number} [params.pageSize] - The number of databases to return per page + * @returns {Promise} - A promise that resolves to the list of databases + * @throws {AuthorizationError | AuthenticationError | CommandError | Error} If the response is not OK + */ +async function listDatabases(params = {}) { + const { path, pageSize = 1000 } = params; + const url = toResource({ + endpoint: "/databases", + params: { + max_results: pageSize, // eslint-disable-line camelcase + ...(path ? { path: standardizeRegion(path) } : {}), + }, + }); + + const response = await fetchWithAccountKey(url, { + method: "GET", + }); + return await responseHandler(response); +} + +/** + * Creates a new key for a specified database. + * + * @param {Object} params - The parameters for creating the key. + * @param {string} params.path - The path of the database, including region group + * @param {string} params.role - The builtin role for the key. + * @param {string | undefined} params.ttl - ISO String for the key's expiration time, optional + * @param {string | undefined} params.name - The name for the key, optional + * @returns {Promise} - A promise that resolves when the key is created. + * @throws {AuthorizationError | AuthenticationError | CommandError | Error} If the response is not OK + */ +async function createKey({ path, role, ttl, name }) { + const url = toResource({ endpoint: "/databases/keys" }); + + const response = await fetchWithAccountKey(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + role, + path: standardizeRegion(path), + ttl, + name, + }), + }); + + return await responseHandler(response); +} + +/** + * The account API module with the currently supported endpoints. + */ +const accountAPI = { + listDatabases, + createKey, + refreshSession, + getSession, +}; + +export default accountAPI; diff --git a/src/lib/account.mjs b/src/lib/account.mjs deleted file mode 100644 index 5caee165..00000000 --- a/src/lib/account.mjs +++ /dev/null @@ -1,125 +0,0 @@ -import { container } from "../cli.mjs"; -import { - AuthenticationError, - AuthorizationError, - CommandError, -} from "./errors.mjs"; -/** - * - * @param {Object} opts - * @param {string} [opts.body] - The body of the request. JSON or form-urlencoded string - * @param {any} [opts.params] - The query parameters of the request - * @param {string} [opts.contentType] - The content type of the request - * @param {string} opts.method - The HTTP method of the request - * @param {string} opts.path - The path of the request to append to base fauna account URL - * @param {string} [opts.secret] - The secret key to use for the request - * @param {boolean} [opts.shouldThrow] - Whether or not to throw an error if the request fails - * @returns {Promise} - The response from the request - */ -export async function makeAccountRequest({ - secret = "", - path, - params = undefined, - method, - body = undefined, - shouldThrow = true, - contentType = "application/json", -}) { - const fetch = container.resolve("fetch"); - const baseUrl = process.env.FAUNA_ACCOUNT_URL ?? "https://account.fauna.com"; - const paramsString = params ? `?${new URLSearchParams(params)}` : ""; - let fullUrl; - try { - fullUrl = new URL(`/api/v1${path}${paramsString}`, baseUrl).href; - } catch (e) { - e.message = `Could not build valid URL out of base url (${baseUrl}), path (${path}), and params string (${paramsString}) built from params (${JSON.stringify( - params, - )}).`; - throw e; - } - - function _getHeaders() { - const headers = { - "content-type": contentType, - }; - if (secret) { - headers.Authorization = `Bearer ${secret}`; - } - return headers; - } - - const fetchArgs = { - method, - headers: _getHeaders(), - redirect: "manual", - }; - - if (body) fetchArgs.body = body; - - const response = await fetch(fullUrl, fetchArgs); - - return parseResponse(response, shouldThrow); -} - -/** - * Throws an error based on the status code of the response - * - * @param {Response} response - * @param {boolean} responseIsJSON - * @throws {AuthenticationError | AuthorizationError | CommandError | Error} - */ -const accountToCommandError = async (response, responseIsJSON) => { - let message = `Failed to make request to Fauna account API [${response.status}]`; - - let { code, reason, body } = {}; - if (responseIsJSON) { - body = await response.json(); - ({ reason, code } = body); - message += `: ${code} - ${reason}`; - } - - // If consumers want to do more with this, they analyze the cause - const responseAsCause = new Error(message); - responseAsCause.status = response.status; - responseAsCause.body = body; - responseAsCause.headers = response.headers; - responseAsCause.code = code; - responseAsCause.reason = reason; - - switch (response.status) { - case 401: - throw new AuthenticationError({ cause: responseAsCause }); - case 403: - throw new AuthorizationError({ cause: responseAsCause }); - case 400: - case 404: - throw new CommandError(reason ?? message, { - cause: responseAsCause, - hideHelp: true, - }); - default: - throw new Error(message, { cause: responseAsCause }); - } -}; - -/** - * Returns the proper result based on the content type of the account API response - * Conditionally throws errors for status codes > 400 - * - * @param {Response} response result of the fetch call to account api - * @param {boolean} shouldThrow whether to ignore an error from the result - * @returns {Promise} - The response from the request - * @throws {AuthenticationError | AuthorizationError | CommandError | Error} - */ -export async function parseResponse(response, shouldThrow) { - const responseType = - response?.headers?.get("content-type") || "application/json"; - const responseIsJSON = responseType.includes("application/json"); - - if (response.status >= 400 && shouldThrow) { - await accountToCommandError(response, responseIsJSON); - } - - const result = responseIsJSON ? await response.json() : await response; - return result; -} diff --git a/src/lib/auth/README.md b/src/lib/auth/README.md index a35c8cbc..40b227d9 100644 --- a/src/lib/auth/README.md +++ b/src/lib/auth/README.md @@ -56,15 +56,12 @@ The `Credentials` class builds an `AccountKeys` and `DatabaseKeys` class. By the Every command is scoped 1:1 with a `profile`, `database`, and `role`. These classes will be scoped to those variables and use them when getting, or refreshing credentials. -As such, no command should need to pull out `argv.secret` and send it around. We only need the `Fauna Client` and `Account Client` to leverage the correct key: +As such, no command should need to pull out `argv.secret` and send it around. We only need the Fauna clients and `accountApi` to leverage the correct key. The `accountApi` does this automatically already through +the `fetchWithAccountKey` helper function. ```javascript -const credentials = container.resolve("credentials"); -const secret = credentials.databaseKeys.getOrRefreshKey(); -const faunaClient = new FaunaClient({ ...options, secret }); - -const accountKey = credentials.accountKeys.getOrRefreshKey(); -const accountClient = new FaunaAccountClient({ ...options, secret }); +const { listDatabases } = container.resolve("accountAPI"); +const results = await listDatabases({ path: "my-db" }); ``` But instead of getting the key and passing it into the every client instance, we can build the key resolution and refresh logic into the client classes directly: diff --git a/src/lib/auth/accountKeys.mjs b/src/lib/auth/accountKeys.mjs index 794e192e..d67aa20d 100644 --- a/src/lib/auth/accountKeys.mjs +++ b/src/lib/auth/accountKeys.mjs @@ -1,6 +1,5 @@ -import { container } from "../../cli.mjs"; +import { container } from "../../config/container.mjs"; import { AuthenticationError, CommandError } from "../errors.mjs"; -import { FaunaAccountClient } from "../fauna-account-client.mjs"; import { AccountKeyStorage } from "../file-util.mjs"; /** @@ -100,14 +99,13 @@ export class AccountKeys { * credentials file. Updates this.key to the new value. If refresh fails, prompts login */ async refreshKey() { + const { refreshSession } = container.resolve("accountAPI"); const existingCreds = this.keyStore.get(); if (!existingCreds?.refreshToken) { this.promptLogin(); } try { - const newAccountKey = await FaunaAccountClient.refreshSession( - existingCreds.refreshToken, - ); + const newAccountKey = await refreshSession(existingCreds.refreshToken); this.keyStore.save({ accountKey: newAccountKey.accountKey, refreshToken: newAccountKey.refreshToken, diff --git a/src/lib/auth/credentials.mjs b/src/lib/auth/credentials.mjs index e7533e6a..634d25bc 100644 --- a/src/lib/auth/credentials.mjs +++ b/src/lib/auth/credentials.mjs @@ -1,8 +1,7 @@ import { asValue, Lifetime } from "awilix"; -import { container } from "../../cli.mjs"; +import { container } from "../../config/container.mjs"; import { ValidationError } from "../errors.mjs"; -import { FaunaAccountClient } from "../fauna-account-client.mjs"; import { isLocal } from "../middleware.mjs"; import { AccountKeys } from "./accountKeys.mjs"; import { DatabaseKeys } from "./databaseKeys.mjs"; @@ -59,8 +58,9 @@ export class Credentials { } async login(accessToken) { - const { accountKey, refreshToken } = - await FaunaAccountClient.getSession(accessToken); + const { getSession } = container.resolve("accountAPI"); + const { accountKey, refreshToken } = await getSession(accessToken); + this.accountKeys.keyStore.save({ accountKey, refreshToken, diff --git a/src/lib/auth/databaseKeys.mjs b/src/lib/auth/databaseKeys.mjs index a7434fee..29c92c47 100644 --- a/src/lib/auth/databaseKeys.mjs +++ b/src/lib/auth/databaseKeys.mjs @@ -1,6 +1,5 @@ -import { container } from "../../cli.mjs"; +import { container } from "../../config/container.mjs"; import { CommandError } from "../errors.mjs"; -import { FaunaAccountClient } from "../fauna-account-client.mjs"; import { SecretKeyStorage } from "../file-util.mjs"; const TTL_DEFAULT_MS = 1000 * 60 * 15; // 15 minutes @@ -113,13 +112,13 @@ export class DatabaseKeys { * @returns {string} - The new secret */ async refreshKey() { + const { createKey } = container.resolve("accountAPI"); this.logger.debug( `Creating new db key for path ${this.path} and role ${this.role}`, "creds", ); const expiration = this.getKeyExpiration(); - const accountClient = new FaunaAccountClient(); - const newSecret = await accountClient.createKey({ + const newSecret = await createKey({ path: this.path, role: this.role, name: "System generated shell key", diff --git a/src/lib/auth/oauth-client.mjs b/src/lib/auth/oauth-client.mjs index 85cfe12e..898f8a5e 100644 --- a/src/lib/auth/oauth-client.mjs +++ b/src/lib/auth/oauth-client.mjs @@ -3,7 +3,7 @@ import http from "http"; import url from "url"; import util from "util"; -import { container } from "../../cli.mjs"; +import { container } from "../../config/container.mjs"; import SuccessPage from "./successPage.mjs"; const ALLOWED_ORIGINS = [ @@ -35,22 +35,37 @@ class OAuthClient { this.state = OAuthClient._generateCSRFToken(); } - getOAuthParams() { + /** + * Gets the OAuth parameters for the OAuth request. + * @param {Object} [overrides] - The parameters for the OAuth request + * @param {string} [overrides.clientId] - The client ID + * @returns {Object} The OAuth parameters + */ + getOAuthParams({ clientId }) { return { - client_id: CLIENT_ID, // eslint-disable-line camelcase - redirect_uri: `${REDIRECT_URI}:${this.port}`, // eslint-disable-line camelcase - code_challenge: this.codeChallenge, // eslint-disable-line camelcase - code_challenge_method: "S256", // eslint-disable-line camelcase - response_type: "code", // eslint-disable-line camelcase + /* eslint-disable camelcase */ + client_id: clientId ?? CLIENT_ID, + redirect_uri: `${REDIRECT_URI}:${this.port}`, + code_challenge: this.codeChallenge, + code_challenge_method: "S256", + response_type: "code", scope: "create_session", state: this.state, + /* eslint-enable camelcase */ }; } - getTokenParams() { + /** + * Gets the token parameters for the OAuth request. + * @param {Object} [overrides] - The parameters for the OAuth request + * @param {string} [overrides.clientId] - The client ID + * @param {string} [overrides.clientSecret] - The client secret + * @returns {Object} The token parameters + */ + getTokenParams({ clientId, clientSecret }) { return { - clientId: CLIENT_ID, - clientSecret: CLIENT_SECRET, + clientId: clientId ?? CLIENT_ID, + clientSecret: clientSecret ?? CLIENT_SECRET, authCode: this.authCode, redirectURI: `${REDIRECT_URI}:${this.port}`, codeVerifier: this.codeVerifier, diff --git a/src/lib/completions.mjs b/src/lib/completions.mjs index ea1d520e..94131820 100644 --- a/src/lib/completions.mjs +++ b/src/lib/completions.mjs @@ -2,7 +2,8 @@ import * as path from "node:path"; -import { FaunaAccountClient } from "../lib/fauna-account-client.mjs"; +import { container } from "../config/container.mjs"; +import { setAccountUrl } from "./account-api.mjs"; import { buildCredentials } from "./auth/credentials.mjs"; import { getConfig, locateConfig } from "./config/config.mjs"; @@ -24,18 +25,29 @@ export async function getDbCompletions(currentWord, argv) { return regionGroups; } else { const { pageSize } = argv; + if (argv.accountUrl !== undefined) { + setAccountUrl(argv.accountUrl); + } buildCredentials({ ...argv, user: "default" }); - const accountClient = new FaunaAccountClient(); + const { listDatabases } = container.resolve("accountAPI"); try { - const response = await accountClient.listDatabases({ + // Try the currentWord with any trailing slash removed + const databasePath = currentWord.endsWith("/") + ? currentWord.slice(0, -1) + : currentWord; + const response = await listDatabases({ pageSize, - path: currentWord, + path: databasePath, }); return response.results.map(({ name }) => path.join(currentWord, name)); } catch (e) { - const response = await accountClient.listDatabases({ + // If the first try fails, try the currentWord with the directory name. + // If this is just a region group, dirname will resolve to '.' and we'll + // not get any results. + const databasePath = path.dirname(currentWord); + const response = await listDatabases({ pageSize, - path: path.dirname(currentWord), + path: databasePath, }); return response.results.map(({ name }) => path.join(path.dirname(currentWord), name), diff --git a/src/lib/config/config.mjs b/src/lib/config/config.mjs index 9f192237..bc748eb7 100644 --- a/src/lib/config/config.mjs +++ b/src/lib/config/config.mjs @@ -1,7 +1,7 @@ import yaml from "yaml"; import yargsParser from "yargs-parser"; -import { container } from "../../cli.mjs"; +import { container } from "../../config/container.mjs"; import { ValidationError } from "../errors.mjs"; export const validDefaultConfigNames = [ diff --git a/src/lib/db.mjs b/src/lib/core-api.mjs similarity index 98% rename from src/lib/db.mjs rename to src/lib/core-api.mjs index 1814fe6e..95f9ce76 100644 --- a/src/lib/db.mjs +++ b/src/lib/core-api.mjs @@ -1,6 +1,6 @@ //@ts-check -import { container } from "../cli.mjs"; +import { container } from "../config/container.mjs"; import { AuthenticationError, CommandError, diff --git a/src/lib/docker-containers.mjs b/src/lib/docker-containers.mjs index 424e9367..2c11a231 100644 --- a/src/lib/docker-containers.mjs +++ b/src/lib/docker-containers.mjs @@ -1,4 +1,4 @@ -import { container } from "../cli.mjs"; +import { container } from "../config/container.mjs"; import { CommandError, SUPPORT_MESSAGE } from "./errors.mjs"; import { colorize, Format } from "./formatting/colorize.mjs"; diff --git a/src/lib/errors.mjs b/src/lib/errors.mjs index 1111ea05..5189ffed 100644 --- a/src/lib/errors.mjs +++ b/src/lib/errors.mjs @@ -2,7 +2,7 @@ import chalk from "chalk"; import hasAnsi from "has-ansi"; import util from "util"; -import { container } from "../cli.mjs"; +import { container } from "../config/container.mjs"; const BUG_REPORT_MESSAGE = "If you believe this is a bug, please report this issue on GitHub: https://github.com/fauna/fauna-shell/issues"; diff --git a/src/lib/fauna-account-client.mjs b/src/lib/fauna-account-client.mjs deleted file mode 100644 index 7d823e65..00000000 --- a/src/lib/fauna-account-client.mjs +++ /dev/null @@ -1,257 +0,0 @@ -//@ts-check - -import { container } from "../cli.mjs"; -import { AuthenticationError } from "./errors.mjs"; - -// const KEY_TTL_DEFAULT_MS = 1000 * 60 * 60 * 24; - -/** - * Class representing a client for interacting with the Fauna account API. - */ -export class FaunaAccountClient { - constructor() { - this.accountKeys = container.resolve("credentials").accountKeys; - - // For requests where we want to retry on 401s, wrap up the original makeAccountRequest - this.retryableAccountRequest = async (args) => { - const original = container.resolve("makeAccountRequest"); - const logger = container.resolve("logger"); - let result; - try { - result = await original(await this.getRequestArgs(args)); - } catch (e) { - if (e instanceof AuthenticationError) { - try { - logger.debug( - "401 in account api, attempting to refresh session", - "creds", - ); - await this.accountKeys.onInvalidCreds(); - // onInvalidCreds will refresh the account key - const updatedArgs = await this.getRequestArgs(args); - result = await original(updatedArgs); - } catch (e) { - if (e instanceof AuthenticationError) { - logger.debug( - "Failed to refresh session, expired or missing refresh token", - "creds", - ); - this.accountKeys.promptLogin(); - } else { - throw e; - } - } - } else { - throw e; - } - } - return result; - }; - } - - // By the time we are inside the retryableAccountRequest, - // the account key will have been refreshed. Use the latest value - async getRequestArgs(args) { - const updatedKey = await this.accountKeys.getOrRefreshKey(); - return { - ...args, - secret: updatedKey, - }; - } - - /** - * Starts an OAuth request to the Fauna account API. - * - * @param {Object} authCodeParams - The parameters for the OAuth authorization code request. - * @returns {Promise} - The URL to the Fauna dashboard for OAuth authorization. - * @throws {Error} - Throws an error if there is an issue during login. - */ - static async startOAuthRequest(authCodeParams) { - const makeAccountRequest = container.resolve("makeAccountRequest"); - const oauthRedirect = await makeAccountRequest({ - path: "/oauth/authorize", - method: "GET", - params: authCodeParams, - contentType: "text/html", - }); - if (oauthRedirect.status !== 302) { - throw new Error( - `Failed to start OAuth request: ${oauthRedirect.status} - ${oauthRedirect.statusText}`, - ); - } - const dashboardOAuthURL = oauthRedirect.headers.get("location"); - const error = new URL(dashboardOAuthURL).searchParams.get("error"); - if (error) { - throw new Error(`Error during login: ${error}`); - } - return dashboardOAuthURL; - } - - /** - * Retrieves an access token from the Fauna account API. - * - * @param {Object} opts - The options for the token request. - * @param {string} opts.clientId - The client ID for the OAuth application. - * @param {string} opts.clientSecret - The client secret for the OAuth application. - * @param {string} opts.authCode - The authorization code received from the OAuth authorization. - * @param {string} opts.redirectURI - The redirect URI for the OAuth application. - * @param {string} opts.codeVerifier - The code verifier for the OAuth PKCE flow. - * @returns {Promise} - The access token. - * @throws {Error} - Throws an error if there is an issue during token retrieval. - */ - static async getToken(opts) { - const makeAccountRequest = container.resolve("makeAccountRequest"); - const params = { - grant_type: "authorization_code", // eslint-disable-line camelcase - client_id: opts.clientId, // eslint-disable-line camelcase - client_secret: opts.clientSecret, // eslint-disable-line camelcase - code: opts.authCode, - redirect_uri: opts.redirectURI, // eslint-disable-line camelcase - code_verifier: opts.codeVerifier, // eslint-disable-line camelcase - }; - try { - const response = await makeAccountRequest({ - method: "POST", - contentType: "application/x-www-form-urlencoded", - body: new URLSearchParams(params).toString(), - path: "/oauth/token", - }); - const { access_token: accessToken } = response; - return accessToken; - } catch (err) { - err.message = `Failure to authorize with Fauna: ${err.message}`; - throw err; - } - } - - /** - * Retrieves the session information from the Fauna account API. - * - * @param {string} accessToken - The access token for the session. - * @returns {Promise<{accountKey: string, refreshToken: string}>} - The session information. - * @throws {Error} - Throws an error if there is an issue during session retrieval. - */ - static async getSession(accessToken) { - const makeAccountRequest = container.resolve("makeAccountRequest"); - try { - const { account_key: accountKey, refresh_token: refreshToken } = - await makeAccountRequest({ - method: "POST", - path: "/session", - secret: accessToken, - }); - return { accountKey, refreshToken }; - } catch (err) { - err.message = `Failure to get session with Fauna: ${err.message}`; - throw err; - } - } - - /** - * Uses refreshToken to get a new accountKey and refreshToken. - * @param {*} refreshToken - * @returns {Promise<{accountKey: string, refreshToken: string}>} - The new session information. - */ - static async refreshSession(refreshToken) { - const makeAccountRequest = container.resolve("makeAccountRequest"); - const { account_key: newAccountKey, refresh_token: newRefreshToken } = - await makeAccountRequest({ - method: "POST", - path: "/session/refresh", - secret: refreshToken, - }); - return { accountKey: newAccountKey, refreshToken: newRefreshToken }; - } - - /** - * Lists databases associated with the given account key. - * - * @param {Object} params - The list databases request parameters. - * @param {string} [params.path] - The path of the database, including region group. - * @param {number} [params.pageSize] - The number of databases to return. Default 1000. - * @returns {Promise<{results: Array<{name: string, path: string, region_group: string, has_children: boolean}>, next_token: string | undefined}>} - The list of databases. - * @throws {Error} - Throws an error if there is an issue during the request. - */ - async listDatabases({ path, pageSize = 1000 }) { - try { - return this.retryableAccountRequest({ - method: "GET", - path: "/databases", - secret: this.accountKeys.key, - // The API expects max_results - params: { - // eslint-disable-next-line camelcase - max_results: pageSize, - ...(path && { path: FaunaAccountClient.standardizeRegion(path) }), - }, - }); - } catch (err) { - err.message = `Failure to list databases: ${err.message}`; - throw err; - } - } - - /** - * Creates a new key for a specified database. - * - * @param {Object} params - The parameters for creating the key. - * @param {string} params.path - The path of the database, including region group - * @param {string} params.role - The builtin role for the key. - * @param {string | undefined} params.ttl - ISO String for the key's expiration time, optional - * @param {string | undefined} params.name - The name for the key, optional - * @returns {Promise} - A promise that resolves when the key is created. - * @throws {Error} - Throws an error if there is an issue during key creation. - */ - async createKey({ path, role, ttl, name }) { - return this.retryableAccountRequest({ - method: "POST", - path: "/databases/keys", - body: JSON.stringify({ - role, - path: FaunaAccountClient.standardizeRegion(path), - ttl, - name, - }), - secret: this.accountKeys.key, - }); - } - - /** - * Transforms database paths to standardize region group naming conventions expected by - * the account API. - * - * @param {string} [databasePath] - The database path to standardize - * @returns {string | undefined} The standardized path - * @example - * // Returns "us-std/my-database" - * FaunaAccountClient.standardizeRegion("us/my-database") - * - * // Returns "eu-std/my-database" - * FaunaAccountClient.standardizeRegion("eu/my-database") - * - * // Returns "global/my-database" - * FaunaAccountClient.standardizeRegion("classic/my-database") - * - * @throws {TypeError} If databasePath is provided but not a string - */ - static standardizeRegion(databasePath) { - if (!databasePath) return databasePath; - if (typeof databasePath !== "string") { - throw new TypeError("Database path must be a string"); - } - - const trimmed = databasePath.replace(/^\/|\/$/g, ""); - const parts = trimmed.split("/"); - const region = parts[0].toLowerCase(); - const rest = parts.slice(1).join("/"); - - const regionMap = { - us: "us-std", - eu: "eu-std", - classic: "global", - }; - - const standardRegion = regionMap[region] || region; - return rest ? `${standardRegion}/${rest}` : standardRegion; - } -} diff --git a/src/lib/fauna-client.mjs b/src/lib/fauna-client.mjs index b38d3144..b7c90219 100644 --- a/src/lib/fauna-client.mjs +++ b/src/lib/fauna-client.mjs @@ -2,7 +2,7 @@ import stripAnsi from "strip-ansi"; -import { container } from "../cli.mjs"; +import { container } from "../config/container.mjs"; import { isUnknownError } from "./errors.mjs"; import { faunaToCommandError } from "./fauna.mjs"; import { faunadbToCommandError } from "./faunadb.mjs"; diff --git a/src/lib/fauna.mjs b/src/lib/fauna.mjs index d2619b52..37b52ec3 100644 --- a/src/lib/fauna.mjs +++ b/src/lib/fauna.mjs @@ -6,7 +6,7 @@ import chalk from "chalk"; import { NetworkError, ServiceError } from "fauna"; -import { container } from "../cli.mjs"; +import { container } from "../config/container.mjs"; import { AuthenticationError, AuthorizationError, diff --git a/src/lib/faunadb.mjs b/src/lib/faunadb.mjs index fa9c77ee..1fd944eb 100644 --- a/src/lib/faunadb.mjs +++ b/src/lib/faunadb.mjs @@ -3,7 +3,7 @@ import { createContext, runInContext } from "node:vm"; import faunadb from "faunadb"; -import { container } from "../cli.mjs"; +import { container } from "../config/container.mjs"; import { AuthenticationError, AuthorizationError, diff --git a/src/lib/fetch-wrapper.mjs b/src/lib/fetch-wrapper.mjs index 194cf457..a30869b5 100644 --- a/src/lib/fetch-wrapper.mjs +++ b/src/lib/fetch-wrapper.mjs @@ -2,7 +2,7 @@ import { inspect } from "node:util"; -import { container } from "../cli.mjs"; +import { container } from "../config/container.mjs"; // this wrapper exists for only one reason: logging // in the future, it could also be extended for error-handling, diff --git a/src/lib/file-util.mjs b/src/lib/file-util.mjs index 4afbd32f..e893ff2b 100644 --- a/src/lib/file-util.mjs +++ b/src/lib/file-util.mjs @@ -1,7 +1,7 @@ //@ts-check import path from "node:path"; -import { container } from "../cli.mjs"; +import { container } from "../config/container.mjs"; /** * Fixes paths by normalizing them (.. => parent directory) and resolving ~ to homedir. diff --git a/src/lib/formatting/colorize.mjs b/src/lib/formatting/colorize.mjs index 88e5a072..2df1f1cc 100644 --- a/src/lib/formatting/colorize.mjs +++ b/src/lib/formatting/colorize.mjs @@ -1,7 +1,7 @@ import stripAnsi from "strip-ansi"; import YAML from "yaml"; -import { container } from "../../cli.mjs"; +import { container } from "../../config/container.mjs"; import { codeToAnsi } from "./codeToAnsi.mjs"; export const Format = { diff --git a/src/lib/middleware.mjs b/src/lib/middleware.mjs index a0f69fa3..98a9a467 100644 --- a/src/lib/middleware.mjs +++ b/src/lib/middleware.mjs @@ -5,12 +5,12 @@ import path from "node:path"; import { isSea } from "node:sea"; import { fileURLToPath } from "node:url"; -import { container } from "../cli.mjs"; +import { container } from "../config/container.mjs"; import { fixPath } from "../lib/file-util.mjs"; +import { setAccountUrl } from "./account-api.mjs"; import { ValidationError } from "./errors.mjs"; import { redactedStringify } from "./formatting/redact.mjs"; import { QUERY_OPTIONS } from "./options.mjs"; - const LOCAL_URL = "http://0.0.0.0:8443"; const LOCAL_SECRET = "secret"; const DEFAULT_URL = "https://db.fauna.com"; @@ -202,3 +202,16 @@ export const validateDatabaseOrSecret = (argv) => { } return true; }; + +/** + * Set the account URL for the current user, changing the base url used for + * all Fauna API requests. + * @param {import('yargs').Arguments} argv + * @returns {import('yargs').Arguments} + */ +export function applyAccountUrl(argv) { + if (argv.accountUrl) { + setAccountUrl(argv.accountUrl); + } + return argv; +} diff --git a/src/lib/schema.mjs b/src/lib/schema.mjs index ab02ea03..44a2ef3d 100644 --- a/src/lib/schema.mjs +++ b/src/lib/schema.mjs @@ -2,8 +2,8 @@ import * as path from "path"; -import { container } from "../cli.mjs"; -import { makeFaunaRequest } from "../lib/db.mjs"; +import { container } from "../config/container.mjs"; +import { makeFaunaRequest } from "./core-api.mjs"; import { getSecret } from "./fauna-client.mjs"; import { dirExists, dirIsWriteable } from "./file-util.mjs"; @@ -163,7 +163,7 @@ export async function writeSchemaFiles(dir, filenameToContentsDict) { await Promise.all(promises); } -/** @typedef {import('./db.mjs').fetchParameters} fetchParameters */ +/** @typedef {import('./core-api.mjs').fetchParameters} fetchParameters */ /** * @param {string[]} filenames - A list of schema file names to fetch diff --git a/src/lib/utils.mjs b/src/lib/utils.mjs index 7462f5ab..95afeb4f 100644 --- a/src/lib/utils.mjs +++ b/src/lib/utils.mjs @@ -1,4 +1,4 @@ -import { container } from "../cli.mjs"; +import { container } from "../config/container.mjs"; import { Format } from "./formatting/colorize.mjs"; export function isTTY() { @@ -18,3 +18,31 @@ export const resolveFormat = (argv) => { return argv.format; }; + +/** + * Standardizes the region of a database path. + * + * @param {string | undefined} databasePath - The database path to standardize. + * @returns {string | undefined} The standardized database path. + * @throws {TypeError} If the database path is not a string. + */ +export function standardizeRegion(databasePath) { + if (!databasePath) return databasePath; + if (typeof databasePath !== "string") { + throw new TypeError("Database path must be a string"); + } + + const trimmed = databasePath.replace(/^\/|\/$/g, ""); + const parts = trimmed.split("/"); + const region = parts[0].toLowerCase(); + const rest = parts.slice(1).join("/"); + + const regionMap = { + us: "us-std", + eu: "eu-std", + classic: "global", + }; + + const standardRegion = regionMap[region] || region; + return rest ? `${standardRegion}/${rest}` : standardRegion; +} diff --git a/test/completions.mjs b/test/completions.mjs index 9076a83a..9c51967c 100644 --- a/test/completions.mjs +++ b/test/completions.mjs @@ -152,10 +152,10 @@ describe("shell completion", () => { .returns(JSON.stringify(basicConfig)); mockAccessKeysFile({ fs }); - let makeAccountRequest = container.resolve("makeAccountRequest"); + const { listDatabases } = container.resolve("accountAPI"); const stubbedResponse = { results: [{ name: "americacentric" }] }; - makeAccountRequest - .withArgs(match({ path: "/databases", params: { path: "us-std" } })) + listDatabases + .withArgs(match({ path: "us-std" })) .resolves(stubbedResponse); }); @@ -171,11 +171,11 @@ describe("shell completion", () => { }); it("suggests a top level database in the selected region group", async () => { - let makeAccountRequest = container.resolve("makeAccountRequest"); - const stubbedResponse = { results: [{ name: "eurocentric" }] }; - makeAccountRequest - .withArgs(match({ path: "/databases", params: { path: "eu-std" } })) - .resolves(stubbedResponse); + const { listDatabases } = container.resolve("accountAPI"); + listDatabases.withArgs(match({ path: "eu-std" })).resolves({ + results: [{ name: "eurocentric" }], + }); + await complete({ container, matchFlag: "database", @@ -189,15 +189,10 @@ describe("shell completion", () => { }); it("suggests a nested level database in the selected region group", async () => { - let makeAccountRequest = container.resolve("makeAccountRequest"); - const stubbedResponse = { + const { listDatabases } = container.resolve("accountAPI"); + listDatabases.withArgs(match({ path: "eu-std/a/b/c/d" })).resolves({ results: [{ name: "1" }, { name: "2" }, { name: "3" }, { name: "4" }], - }; - makeAccountRequest - .withArgs( - match({ path: "/databases", params: { path: "eu-std/a/b/c/d" } }), - ) - .resolves(stubbedResponse); + }); await complete({ container, diff --git a/test/credentials.mjs b/test/credentials.mjs index f1305ca9..9c82c044 100644 --- a/test/credentials.mjs +++ b/test/credentials.mjs @@ -3,11 +3,12 @@ import * as nodeFs from "node:fs"; import * as awilix from "awilix"; import { expect } from "chai"; import path from "path"; -import sinon, { spy } from "sinon"; +import sinon from "sinon"; import { run } from "../src/cli.mjs"; import { setupTestContainer as setupContainer } from "../src/config/setup-test-container.mjs"; -import { makeAccountRequest as originalMakeAccountRequest } from "../src/lib/account.mjs"; +import originalAccountAPI from "../src/lib/account-api.mjs"; +import { AuthenticationError } from "../src/lib/errors.mjs"; import { runQueryFromString as originalRunQueryFromString } from "../src/lib/fauna-client.mjs"; import { f } from "./helpers.mjs"; @@ -27,7 +28,7 @@ const defaultDatabaseKeysFile = { // Ensure the credentials middleware correctly sets and refreshes account and database keys describe("credentials", function () { - let container, stderr, fs, fetch, credsDir, makeAccountRequest; + let container, stderr, fs, credsDir, accountAPI, fetch; // Instead of mocking `fs` to return certain values in each test, let the middleware // read files in the actual filesystem. @@ -50,12 +51,13 @@ describe("credentials", function () { fs: awilix.asValue(nodeFs), }); fs = container.resolve("fs"); + accountAPI = container.resolve("accountAPI"); fetch = container.resolve("fetch"); - makeAccountRequest = container.resolve("makeAccountRequest"); const homedir = container.resolve("homedir")(); credsDir = path.join(homedir, ".fauna/credentials"); setCredsFiles(defaultAccountKeysFile, defaultDatabaseKeysFile); + delete process.env.FAUNA_ACCOUNT_KEY; delete process.env.FAUNA_SECRET; }); @@ -160,11 +162,6 @@ describe("credentials", function () { // Test various network-dependent functionality of the credentials middleware around account keys describe("account keys", () => { - beforeEach(() => { - container.register({ - makeAccountRequest: awilix.asValue(spy(originalMakeAccountRequest)), - }); - }); it("prompts login when account key and refresh token are empty", async () => { try { setCredsFiles({}, {}); @@ -181,12 +178,8 @@ describe("credentials", function () { it("prompts login when refresh token is invalid", async () => { setCredsFiles(defaultAccountKeysFile, {}); - fetch - .withArgs( - sinon.match(/\/session\/refresh/), - sinon.match({ method: "POST" }), - ) - .resolves(f({}, 401)); + accountAPI.refreshSession.rejects(new AuthenticationError()); + try { await run( `query "Database.all()" -d us-std --no-color --json`, @@ -201,43 +194,29 @@ describe("credentials", function () { it("refreshes account key", async () => { setCredsFiles(defaultAccountKeysFile, {}); - fetch - .withArgs( - sinon.match(/\/databases\/keys/), - sinon.match({ method: "POST" }), - ) - .onCall(0) - .resolves(f({}, 401)); + // We want to test the refresh logic, so we need to can't mock the createKey function + const refreshSession = sinon.stub(); + container.register({ + accountAPI: awilix.asValue({ + createKey: originalAccountAPI.createKey, + refreshSession, + }), + }); + + fetch.onCall(0).resolves(f({}, 401)); + + refreshSession.resolves({ + accountKey: "new-account-key", + refreshToken: "new-refresh-token", + }); + + fetch.onCall(1).resolves(f({ secret: "new-secret" })); - fetch - .withArgs( - sinon.match(/\/session\/refresh/), - sinon.match({ method: "POST" }), - ) - .resolves( - f( - { - account_key: "new-account-key", - refresh_token: "new-refresh-token", - }, - 200, - ), - ); await run( `query "Database.all()" -d us-std --no-color --json`, container, ); - const makeAccountRequest = container.resolve("makeAccountRequest"); - [ - ["/databases/keys", "some-account-key"], - ["/session/refresh", "some-refresh-token"], - ["/databases/keys", "new-account-key"], - ].forEach((args, i) => { - sinon.assert.calledWithMatch( - makeAccountRequest.getCall(i), - sinon.match({ path: args[0], secret: args[1] }), - ); - }); + const credentials = container.resolve("credentials"); expect(credentials.accountKeys).to.deep.include({ key: "new-account-key", @@ -313,20 +292,15 @@ describe("credentials", function () { .resolves({ data: [], }); - makeAccountRequest - .withArgs( - sinon.match({ - path: sinon.match(/\/databases\/keys/), - method: "POST", - }), - ) - .resolves({ - secret: "new-secret", - }); + accountAPI.createKey.onCall(0).resolves({ + secret: "new-secret", + }); + await run( `query "Database.all()" -d us-std --no-color --json`, container, ); + sinon.assert.calledWithMatch( v10runQueryFromString.getCall(0), sinon.match({ @@ -334,6 +308,7 @@ describe("credentials", function () { secret: "some-database-secret", }), ); + const credentials = container.resolve("credentials"); expect(credentials.databaseKeys).to.deep.include({ role: "admin", diff --git a/test/database/create.mjs b/test/database/create.mjs index 4033ddee..a7500992 100644 --- a/test/database/create.mjs +++ b/test/database/create.mjs @@ -10,14 +10,14 @@ import { AUTHENTICATION_ERROR_MESSAGE } from "../../src/lib/errors.mjs"; import { mockAccessKeysFile } from "../helpers.mjs"; describe("database create", () => { - let container, logger, runQuery, makeAccountRequest; + let container, logger, runQuery, accountAPI; beforeEach(() => { // reset the container before each test container = setupContainer(); logger = container.resolve("logger"); runQuery = container.resolve("faunaClientV10").runQuery; - makeAccountRequest = container.resolve("makeAccountRequest"); + accountAPI = container.resolve("accountAPI"); }); [ @@ -152,7 +152,7 @@ describe("database create", () => { // If we are using a user provided secret, we should not // need to call the account api to mint or refresh a key. - expect(makeAccountRequest).to.not.have.been.called; + expect(accountAPI.createKey).to.not.have.been.called; }); }); @@ -176,7 +176,7 @@ describe("database create", () => { ); } catch (e) {} - expect(makeAccountRequest).to.not.have.been.called; + expect(accountAPI.createKey).to.not.have.been.called; expect(logger.stderr).to.have.been.calledWith( sinon.match(AUTHENTICATION_ERROR_MESSAGE), ); @@ -214,16 +214,16 @@ describe("database create", () => { mockAccessKeysFile({ fs: container.resolve("fs") }); // We will attempt to mint a new database key, mock the response // so we can verify that the new key is used. - makeAccountRequest.resolves({ secret: "new-secret" }); + accountAPI.createKey.resolves({ secret: "new-secret" }); await run(`database create ${args}`, container); // Verify that we made a request to mint a new database key. - expect(makeAccountRequest).to.have.been.calledOnceWith({ - method: "POST", - path: "/databases/keys", - body: sinon.match((value) => value.includes(expected.database)), - secret: sinon.match.string, + expect(accountAPI.createKey).to.have.been.calledOnceWith({ + path: "us/example", + role: "admin", + ttl: sinon.match.string, + name: sinon.match.string, }); // Verify that we made a request to create the database with the new key. diff --git a/test/database/delete.mjs b/test/database/delete.mjs index 712b73ca..a2591eab 100644 --- a/test/database/delete.mjs +++ b/test/database/delete.mjs @@ -10,14 +10,14 @@ import { AUTHENTICATION_ERROR_MESSAGE } from "../../src/lib/errors.mjs"; import { mockAccessKeysFile } from "../helpers.mjs"; describe("database delete", () => { - let container, logger, runQuery, makeAccountRequest; + let container, logger, runQuery, accountAPI; beforeEach(() => { // reset the container before each test container = setupContainer(); logger = container.resolve("logger"); runQuery = container.resolve("faunaClientV10").runQuery; - makeAccountRequest = container.resolve("makeAccountRequest"); + accountAPI = container.resolve("accountAPI"); }); [ @@ -94,7 +94,7 @@ describe("database delete", () => { // If we are using a user provided secret, we should not // need to call the account api to mint or refresh a key. - expect(makeAccountRequest).to.not.have.been.called; + expect(accountAPI.createKey).to.not.have.been.called; }); }); @@ -118,7 +118,7 @@ describe("database delete", () => { ); } catch (e) {} - expect(makeAccountRequest).to.not.have.been.called; + expect(accountAPI.createKey).to.not.have.been.called; expect(logger.stderr).to.have.been.calledWith( sinon.match(AUTHENTICATION_ERROR_MESSAGE), ); @@ -136,16 +136,16 @@ describe("database delete", () => { mockAccessKeysFile({ fs: container.resolve("fs") }); // We will attempt to mint a new database key, mock the response // so we can verify that the new key is used. - makeAccountRequest.resolves({ secret: "new-secret" }); + accountAPI.createKey.resolves({ secret: "new-secret" }); await run(`database delete ${args}`, container); // Verify that we made a request to mint a new database key. - expect(makeAccountRequest).to.have.been.calledOnceWith({ - method: "POST", - path: "/databases/keys", - body: sinon.match((value) => value.includes(expected.database)), - secret: sinon.match.string, + expect(accountAPI.createKey).to.have.been.calledOnceWith({ + path: "us/example", + role: "admin", + ttl: sinon.match.string, + name: sinon.match.string, }); // Verify that we made a request to delete the database with the new key. diff --git a/test/database/list.mjs b/test/database/list.mjs index bd951254..ac8511a4 100644 --- a/test/database/list.mjs +++ b/test/database/list.mjs @@ -11,7 +11,7 @@ import { colorize } from "../../src/lib/formatting/colorize.mjs"; import { mockAccessKeysFile } from "../helpers.mjs"; describe("database list", () => { - let container, fs, logger, stdout, runQueryFromString, makeAccountRequest; + let container, fs, logger, stdout, runQueryFromString, accountAPI; beforeEach(() => { // reset the container before each test @@ -19,7 +19,7 @@ describe("database list", () => { fs = container.resolve("fs"); logger = container.resolve("logger"); runQueryFromString = container.resolve("faunaClientV10").runQueryFromString; - makeAccountRequest = container.resolve("makeAccountRequest"); + accountAPI = container.resolve("accountAPI"); stdout = container.resolve("stdoutStream"); }); @@ -63,7 +63,7 @@ describe("database list", () => { await stdout.waitForWritten(); expect(stdout.getWritten()).to.equal("testdb\ntestdb2\n"); - expect(makeAccountRequest).to.not.have.been.called; + expect(accountAPI.listDatabases).to.not.have.been.called; }); }); }); @@ -92,7 +92,7 @@ describe("database list", () => { }); expect(stdout.getWritten()).to.equal("testdb\n"); - expect(makeAccountRequest).to.not.have.been.called; + expect(accountAPI.listDatabases).to.not.have.been.called; }); }); @@ -129,7 +129,7 @@ describe("database list", () => { expected: { pageSize: 10, regionGroup: "us-std" }, }, { - args: "--database 'us/example'", + args: "--database 'us-std/example'", expected: { database: "us-std/example" }, }, ].forEach(({ args, expected }) => { @@ -148,18 +148,13 @@ describe("database list", () => { }, ], }; - makeAccountRequest.resolves(stubbedResponse); + accountAPI.listDatabases.resolves(stubbedResponse); await run(`database list ${args}`, container); - expect(makeAccountRequest).to.have.been.calledOnceWith({ - method: "GET", - path: "/databases", - secret: sinon.match.string, - params: { - max_results: expected.pageSize ?? 1000, - ...(expected.database && { path: expected.database }), - }, + expect(accountAPI.listDatabases).to.have.been.calledOnceWith({ + pageSize: expected.pageSize ?? 1000, + path: expected.database, }); expect(stdout.getWritten()).to.equal( @@ -190,7 +185,7 @@ describe("database list", () => { name: "test", }, ]; - makeAccountRequest.resolves({ + accountAPI.listDatabases.resolves({ results: data, }); } diff --git a/test/lib/account-api.mjs b/test/lib/account-api.mjs new file mode 100644 index 00000000..98de28fe --- /dev/null +++ b/test/lib/account-api.mjs @@ -0,0 +1,409 @@ +import * as awilix from "awilix"; +import { expect } from "chai"; +import sinon from "sinon"; + +import { setContainer } from "../../src/config/container.mjs"; +import { setupTestContainer as setupContainer } from "../../src/config/setup-test-container.mjs"; +import accountAPI, { + fetchWithAccountKey, + responseHandler, + toResource, +} from "../../src/lib/account-api.mjs"; +import { + AuthenticationError, + AuthorizationError, + CommandError, +} from "../../src/lib/errors.mjs"; +import { f } from "../helpers.mjs"; + +describe("toResource", () => { + it("should build a URL with the correct endpoint and parameters", () => { + const url = toResource({ endpoint: "/users", params: { limit: 10 } }); + expect(url.toString()).to.equal( + "https://account.fauna.com/api/v1/users?limit=10", + ); + }); + + it("should respect v2 endpoints when specified", () => { + const url = toResource({ + endpoint: "/users", + params: { limit: 10 }, + version: "/v2", + }); + expect(url.toString()).to.equal( + "https://account.fauna.com/v2/users?limit=10", + ); + }); +}); + +describe("responseHandler", () => { + const createMockResponse = ( + status, + body = {}, + contentType = "application/json", + ) => { + return { + ok: status >= 200 && status < 300, + status, + headers: { + get: () => contentType, + }, + json: async () => body, + }; + }; + + it("should standardize v1 and v2 endpoint errors to the same CommandError", async () => { + const v1Response = createMockResponse(400, { + code: "bad_request", + reason: "Database is not specified", + }); + const v2Response = createMockResponse(400, { + error: { + code: "bad_request", + message: "Database is not specified", + }, + }); + + let v1Error, v2Error; + try { + await responseHandler(v1Response); + } catch (error) { + v1Error = error; + } + + try { + await responseHandler(v2Response); + } catch (error) { + v2Error = error; + } + + // Check that the errors are equal instances of a CommandError + expect(v1Error).to.be.instanceOf(CommandError); + expect(v2Error).to.be.instanceOf(CommandError); + expect(v1Error.message).to.equal(v2Error.message); + expect(v1Error.cause).to.deep.equal(v2Error.cause); + + // Check that the errors have the correct code and message + expect(v1Error.message).to.equal("Database is not specified"); + }); + + it("should throw AuthenticationError for 401 status", async () => { + const response = createMockResponse(401, { + code: "unauthorized", + reason: "Invalid credentials", + }); + + try { + await responseHandler(response); + } catch (error) { + expect(error).to.be.instanceOf(AuthenticationError); + } + }); + + it("should throw AuthorizationError for 403 status", async () => { + const response = createMockResponse(403, { + code: "permission_denied", + reason: "Insufficient permissions", + }); + + try { + await responseHandler(response); + } catch (error) { + expect(error).to.be.instanceOf(AuthorizationError); + } + }); + + it("should throw CommandError for 400 status", async () => { + const response = createMockResponse(400, { + code: "bad_request", + reason: "Invalid parameters", + }); + + try { + await responseHandler(response); + } catch (error) { + expect(error).to.be.instanceOf(CommandError); + } + }); + + it("should throw CommandError for 404 status", async () => { + const response = createMockResponse(404, { + code: "not_found", + reason: "Resource not found", + }); + + try { + await responseHandler(response); + } catch (error) { + expect(error).to.be.instanceOf(CommandError); + } + }); + + it("should throw generic Error for other error status codes", async () => { + const response = createMockResponse(500, { + code: "internal_error", + reason: "This is a server error", + }); + + try { + await responseHandler(response); + } catch (error) { + expect(error).to.be.instanceOf(Error); + } + }); + + it("should handle non-JSON responses", async () => { + const response = { + status: 400, + headers: { + get: () => "text/plain", + }, + }; + + try { + await responseHandler(response); + } catch (error) { + expect(error).to.be.instanceOf(CommandError); + expect(error.message).to.equal( + "An unknown error occurred while making a request to the Account API.", + ); + } + }); + + it("should preserve error details in cause", async () => { + const responseBody = { + code: "bad_request", + reason: "Invalid parameters", + }; + const response = createMockResponse(400, responseBody); + + try { + await responseHandler(response); + } catch (error) { + expect(error.cause).to.exist; + expect(error.cause.status).to.equal(400); + expect(error.cause.body).to.deep.equal(responseBody); + expect(error.cause.code).to.equal("bad_request"); + expect(error.cause.message).to.equal("Invalid parameters"); + } + }); + + it("should return parsed JSON for successful responses", async () => { + const responseBody = { data: "success" }; + const response = createMockResponse(200, responseBody); + + const result = await responseHandler(response); + expect(result).to.deep.equal(responseBody); + }); +}); + +describe("accountAPI", () => { + let container, fetch; + + beforeEach(() => { + container = setupContainer(); + fetch = container.resolve("fetch"); + + container.register({ + credentials: awilix.asValue({ + accountKeys: { + key: "some-account-key", + onInvalidCreds: async () => { + container.resolve("credentials").accountKeys.key = + "new-account-key"; + return Promise.resolve(); + }, + promptLogin: sinon.stub(), + }, + }), + }); + + setContainer(container); + }); + + describe("fetchWithAccountKey", () => { + it("should call the endpoint with the correct headers", async () => { + await fetchWithAccountKey("https://account.fauna.com/api/v1/databases", { + method: "GET", + }); + + expect(fetch).to.have.been.calledWith( + "https://account.fauna.com/api/v1/databases", + { + method: "GET", + headers: { + Authorization: "Bearer some-account-key", + }, + }, + ); + }); + + it("should retry once when the response is a 401", async () => { + fetch + .withArgs("https://account.fauna.com/api/v1/databases") + .onCall(0) + .resolves(f(null, 401)); + + fetch + .withArgs("https://account.fauna.com/api/v1/databases") + .onCall(1) + .resolves(f({ results: [] }, 200)); + + const response = await fetchWithAccountKey( + "https://account.fauna.com/api/v1/databases", + { + method: "GET", + }, + ); + + expect(fetch).to.have.been.calledWith( + "https://account.fauna.com/api/v1/databases", + { + method: "GET", + headers: { + Authorization: "Bearer some-account-key", + }, + }, + ); + expect(fetch).to.have.been.calledWith( + "https://account.fauna.com/api/v1/databases", + { + method: "GET", + headers: { + Authorization: "Bearer new-account-key", + }, + }, + ); + expect(await response.json()).to.deep.equal({ results: [] }); + }); + + it("should only retry authorization errors once", async () => { + fetch + .withArgs("https://account.fauna.com/api/v1/databases") + .resolves(f(null, 401)); + + const response = await fetchWithAccountKey( + "https://account.fauna.com/api/v1/databases", + { + method: "GET", + }, + ); + + expect(response.status).to.equal(401); + expect(await response.json()).to.deep.equal(null); + }); + }); + + describe("listDatabases", () => { + it("should call the endpoint", async () => { + fetch + .withArgs( + sinon.match({ + href: "https://account.fauna.com/api/v1/databases?max_results=1000", + }), + sinon.match.any, + ) + .resolves( + f({ + results: [{ name: "test-db", path: "us-std/test-db" }], + }), + ); + + const data = await accountAPI.listDatabases({}); + + expect(fetch).to.have.been.calledWith( + sinon.match({ + href: "https://account.fauna.com/api/v1/databases?max_results=1000", + }), + sinon.match({ + method: "GET", + headers: { + Authorization: "Bearer some-account-key", + }, + }), + ); + + expect(data).to.deep.equal({ + results: [{ name: "test-db", path: "us-std/test-db" }], + }); + }); + + it("should call the endpoint with a path", async () => { + fetch + .withArgs( + sinon.match({ + href: "https://account.fauna.com/api/v1/databases?max_results=1000&path=us-std%2Ftest-db", + }), + ) + .resolves( + f({ + results: [{ name: "test-db", path: "us-std/test-db" }], + }), + ); + + const data = await accountAPI.listDatabases({ path: "us-std/test-db" }); + + expect(data).to.deep.equal({ + results: [{ name: "test-db", path: "us-std/test-db" }], + }); + }); + }); + + describe("createKey", () => { + it("should call the endpoint", async () => { + fetch + .withArgs( + sinon.match({ + href: "https://account.fauna.com/api/v1/databases/keys", + }), + sinon.match.any, + ) + .resolves( + f( + { + id: "key-id", + role: "admin", + path: "us-std/test-db", + ttl: "2025-01-01T00:00:00.000Z", + name: "test-key", + }, + 201, + ), + ); + + const data = await accountAPI.createKey({ + path: "us/test-db", + role: "admin", + ttl: "2025-01-01T00:00:00.000Z", + name: "test-key", + }); + + expect(fetch).to.have.been.calledWith( + sinon.match({ + href: "https://account.fauna.com/api/v1/databases/keys", + }), + sinon.match({ + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: "Bearer some-account-key", + }, + body: JSON.stringify({ + role: "admin", + path: "us-std/test-db", + ttl: "2025-01-01T00:00:00.000Z", + name: "test-key", + }), + }), + ); + + expect(data).to.deep.equal({ + id: "key-id", + role: "admin", + path: "us-std/test-db", + ttl: "2025-01-01T00:00:00.000Z", + name: "test-key", + }); + }); + }); +}); diff --git a/test/lib/account.mjs b/test/lib/account.mjs deleted file mode 100644 index c887be7e..00000000 --- a/test/lib/account.mjs +++ /dev/null @@ -1,159 +0,0 @@ -import { expect } from "chai"; - -import { parseResponse } from "../../src/lib/account.mjs"; -import { - AuthenticationError, - AuthorizationError, - CommandError, -} from "../../src/lib/errors.mjs"; - -describe("parseResponse", () => { - const createMockResponse = ( - status, - body = {}, - contentType = "application/json", - ) => { - return { - status, - headers: { - get: () => contentType, - }, - json: async () => body, - }; - }; - - it("should throw AuthenticationError for 401 status", async () => { - const response = createMockResponse(401, { - code: "unauthorized", - reason: "Invalid credentials", - }); - - try { - await parseResponse(response, true); - } catch (error) { - expect(error).to.be.instanceOf(AuthenticationError); - } - }); - - it("should throw AuthorizationError for 403 status", async () => { - const response = createMockResponse(403, { - code: "permission_denied", - reason: "Insufficient permissions", - }); - - try { - await parseResponse(response, true); - } catch (error) { - expect(error).to.be.instanceOf(AuthorizationError); - } - }); - - it("should throw CommandError for 400 status", async () => { - const response = createMockResponse(400, { - code: "bad_request", - reason: "Invalid parameters", - }); - - try { - await parseResponse(response, true); - } catch (error) { - expect(error).to.be.instanceOf(CommandError); - } - }); - - it("should throw CommandError for 404 status", async () => { - const response = createMockResponse(404, { - code: "not_found", - reason: "Resource not found", - }); - - try { - await parseResponse(response, true); - } catch (error) { - expect(error).to.be.instanceOf(CommandError); - } - }); - - it("should throw generic Error for other error status codes", async () => { - const response = createMockResponse(500, { - code: "internal_error", - reason: "Server error", - }); - - try { - await parseResponse(response, true); - } catch (error) { - expect(error).to.be.instanceOf(Error); - } - }); - - it("should include status code in error message", async () => { - const response = createMockResponse(500, { - code: "internal_error", - reason: "Server error", - }); - - try { - await parseResponse(response, true); - } catch (error) { - expect(error.message).to.include("[500]"); - expect(error.message).to.include("internal_error"); - expect(error.message).to.include("Server error"); - } - }); - - it("should not throw error when shouldThrow is false", async () => { - const response = createMockResponse(400, { - code: "bad_request", - reason: "Invalid parameters", - }); - - const result = await parseResponse(response, false); - expect(result).to.deep.equal({ - code: "bad_request", - reason: "Invalid parameters", - }); - }); - - it("should handle non-JSON responses", async () => { - const response = { - status: 400, - headers: { - get: () => "text/plain", - }, - }; - - try { - await parseResponse(response, true); - } catch (error) { - expect(error).to.be.instanceOf(CommandError); - expect(error.message).to.include("[400]"); - } - }); - - it("should preserve error details in cause", async () => { - const responseBody = { - code: "bad_request", - reason: "Invalid parameters", - }; - const response = createMockResponse(400, responseBody); - - try { - await parseResponse(response, true); - } catch (error) { - expect(error.cause).to.exist; - expect(error.cause.status).to.equal(400); - expect(error.cause.body).to.deep.equal(responseBody); - expect(error.cause.code).to.equal("bad_request"); - expect(error.cause.reason).to.equal("Invalid parameters"); - } - }); - - it("should return parsed JSON for successful responses", async () => { - const responseBody = { data: "success" }; - const response = createMockResponse(200, responseBody); - - const result = await parseResponse(response, true); - expect(result).to.deep.equal(responseBody); - }); -}); diff --git a/test/fauna-account-client.mjs b/test/lib/utils.mjs similarity index 85% rename from test/fauna-account-client.mjs rename to test/lib/utils.mjs index 8a429169..754fdce8 100644 --- a/test/fauna-account-client.mjs +++ b/test/lib/utils.mjs @@ -2,9 +2,9 @@ import { expect } from "chai"; -import { FaunaAccountClient } from "../src/lib/fauna-account-client.mjs"; +import { standardizeRegion } from "../../src/lib/utils.mjs"; -describe("FaunaAccountClient", () => { +describe("standardizeRegion", () => { [ // Edge cases { original: undefined, expected: undefined }, @@ -28,7 +28,7 @@ describe("FaunaAccountClient", () => { { original: "global/example", expected: "global/example" }, ].forEach(({ original, expected }) => { it(`standardizes ${original} to ${expected}`, () => { - expect(FaunaAccountClient.standardizeRegion(original)).to.equal(expected); + expect(standardizeRegion(original)).to.equal(expected); }); }); }); diff --git a/test/login.mjs b/test/login.mjs index 907684fc..27ea5779 100644 --- a/test/login.mjs +++ b/test/login.mjs @@ -6,9 +6,10 @@ import sinon, { spy } from "sinon"; import { run } from "../src/cli.mjs"; import { setupTestContainer as setupContainer } from "../src/config/setup-test-container.mjs"; +import { f } from "./helpers.mjs"; describe("login", function () { - let container, fs, makeAccountRequest; + let container, fs, fetch, getSession; const mockOAuth = () => { let handlers = {}; @@ -53,11 +54,12 @@ describe("login", function () { oauthClient: awilix.asFunction(mockOAuth).scoped(), }); fs = container.resolve("fs"); - makeAccountRequest = container.resolve("makeAccountRequest"); - makeAccountRequest + fetch = container.resolve("fetch"); + getSession = container.resolve("accountAPI").getSession; + fetch .withArgs( + sinon.match({ pathname: "/api/v1/oauth/authorize" }), sinon.match({ - path: sinon.match(/\/oauth\/authorize/), method: "GET", }), ) @@ -66,24 +68,21 @@ describe("login", function () { status: 302, }) .withArgs( + sinon.match({ pathname: "/api/v1/oauth/token" }), sinon.match({ - path: sinon.match(/\/oauth\/token/), method: "POST", }), ) - .resolves({ - access_token: "access-token", - }) - .withArgs( - sinon.match({ - path: sinon.match(/\/session/), - method: "POST", + .resolves( + f({ + access_token: "access-token", }), - ) - .resolves({ - account_key: "login-account-key", - refresh_token: "login-refresh-token", - }); + ); + + getSession.resolves({ + accountKey: "login-account-key", + refreshToken: "login-refresh-token", + }); }); it("can login", async function () { @@ -116,6 +115,7 @@ describe("login", function () { expect(logger.stdout).to.have.been.calledWith( "To login, open your browser to:\nhttp://dashboard-url.com", ); + // Trigger server event with mocked auth code await oauthClient._receiveAuthCode(); diff --git a/test/mocha-root-hooks.mjs b/test/mocha-root-hooks.mjs index ae6b09d4..07e67df1 100644 --- a/test/mocha-root-hooks.mjs +++ b/test/mocha-root-hooks.mjs @@ -4,6 +4,9 @@ import * as chai from "chai"; import sinon from "sinon"; import sinonChai from "sinon-chai"; +import { setContainer } from "../src/config/container.mjs"; +import { setupTestContainer as setupContainer } from "../src/config/setup-test-container.mjs"; + chai.use(sinonChai); // these are mocha root hooks, they're registered for _all_ files in the test run @@ -12,7 +15,11 @@ chai.use(sinonChai); // (this is done for you in the package.json scripts) // https://mochajs.org/#-require-module-r-module export const mochaHooks = { - beforeAll() {}, + beforeAll() { + setContainer(setupContainer()); + }, + // NOTE: We _could_ use setContainer here, but it slows down the tests. Given the amount of tests that require + // access to the container outside of run is low, we are not going to reset the container for each test here. beforeEach() {}, afterAll() {}, afterEach() {