From 68f87fe05a3dcc558c40a040fb8dce35531e5e8b Mon Sep 17 00:00:00 2001 From: Paul Paterson Date: Fri, 17 Jan 2025 09:26:56 -0500 Subject: [PATCH 1/2] FE-6296 Enable scoped database and role with user-provided keys (#558) * add scopeSecret middleware * more specific types break application of middleware * refactor getSecret a bit * debug correct keys * formatting * Update help strings w/ Paul's permission --------- Co-authored-by: James Rodewig --- src/cli.mjs | 3 +- src/commands/database/create.mjs | 2 +- src/commands/database/delete.mjs | 2 +- src/commands/local.mjs | 2 -- src/commands/query.mjs | 2 +- src/commands/schema/abandon.mjs | 2 +- src/commands/schema/commit.mjs | 2 +- src/commands/schema/diff.mjs | 2 +- src/commands/schema/pull.mjs | 2 +- src/commands/schema/push.mjs | 2 +- src/commands/schema/status.mjs | 2 +- src/commands/shell.mjs | 4 +-- src/lib/auth/credentials.mjs | 17 +++++++--- src/lib/fauna-client.mjs | 11 ++++--- src/lib/middleware.mjs | 49 ++++++++++++++++++++++------- src/lib/options.mjs | 5 ++- src/lib/schema.mjs | 2 +- test/commands/database/database.mjs | 48 ---------------------------- test/lib/middleware.mjs | 24 +++++++------- 19 files changed, 84 insertions(+), 99 deletions(-) delete mode 100644 test/commands/database/database.mjs diff --git a/src/cli.mjs b/src/cli.mjs index f770a075..a231d10e 100644 --- a/src/cli.mjs +++ b/src/cli.mjs @@ -24,6 +24,7 @@ import { checkForUpdates, fixPaths, logArgv, + scopeSecret, } from "./lib/middleware.mjs"; /** @type {import('yargs').Argv} */ @@ -111,7 +112,7 @@ function buildYargs(argvInput) { .config("config", configParser.bind(null, argvInput)) .middleware([checkForUpdates, logArgv], true) .middleware( - [applyLocalArg, fixPaths, applyAccountUrl, buildCredentials], + [applyLocalArg, fixPaths, applyAccountUrl, buildCredentials, scopeSecret], false, ) .command(queryCommand) diff --git a/src/commands/database/create.mjs b/src/commands/database/create.mjs index 0f97c04e..61720ab9 100644 --- a/src/commands/database/create.mjs +++ b/src/commands/database/create.mjs @@ -25,7 +25,7 @@ async function runCreateQuery(secret, argv) { } async function createDatabase(argv) { - const secret = await getSecret(); + const secret = await getSecret(argv); const logger = container.resolve("logger"); try { diff --git a/src/commands/database/delete.mjs b/src/commands/database/delete.mjs index b98d7dca..ccafaac3 100644 --- a/src/commands/database/delete.mjs +++ b/src/commands/database/delete.mjs @@ -19,7 +19,7 @@ async function runDeleteQuery(secret, argv) { } async function deleteDatabase(argv) { - const secret = await getSecret(); + const secret = await getSecret(argv); const logger = container.resolve("logger"); try { diff --git a/src/commands/local.mjs b/src/commands/local.mjs index 4c1e331b..8a802036 100644 --- a/src/commands/local.mjs +++ b/src/commands/local.mjs @@ -45,8 +45,6 @@ async function createDatabaseSchema(argv) { }, ), ); - // hack to let us push schema to the local database - argv.secret = `secret:${argv.database}:admin`; await pushSchema({ ...argv, active: true, input: false }); logger.stderr( colorize( diff --git a/src/commands/query.mjs b/src/commands/query.mjs index ed74e28e..7293a09b 100644 --- a/src/commands/query.mjs +++ b/src/commands/query.mjs @@ -87,7 +87,7 @@ async function queryCommand(argv) { // get the query handler and run the query try { - const secret = await getSecret(); + const secret = await getSecret(argv); const { url, timeout, diff --git a/src/commands/schema/abandon.mjs b/src/commands/schema/abandon.mjs index 80c200e9..a898b3f1 100644 --- a/src/commands/schema/abandon.mjs +++ b/src/commands/schema/abandon.mjs @@ -8,7 +8,7 @@ async function doAbandon(argv) { const makeFaunaRequest = container.resolve("makeFaunaRequest"); const logger = container.resolve("logger"); const confirm = container.resolve("confirm"); - const secret = await getSecret(); + const secret = await getSecret(argv); if (!argv.input) { const params = new URLSearchParams({ diff --git a/src/commands/schema/commit.mjs b/src/commands/schema/commit.mjs index 75b22a04..df34c0a6 100644 --- a/src/commands/schema/commit.mjs +++ b/src/commands/schema/commit.mjs @@ -8,7 +8,7 @@ async function doCommit(argv) { const makeFaunaRequest = container.resolve("makeFaunaRequest"); const logger = container.resolve("logger"); const confirm = container.resolve("confirm"); - const secret = await getSecret(); + const secret = await getSecret(argv); if (!argv.input) { const params = new URLSearchParams({ diff --git a/src/commands/schema/diff.mjs b/src/commands/schema/diff.mjs index 9bbe1213..5ddfa73e 100644 --- a/src/commands/schema/diff.mjs +++ b/src/commands/schema/diff.mjs @@ -61,7 +61,7 @@ async function doDiff(argv) { const gatherFSL = container.resolve("gatherFSL"); const logger = container.resolve("logger"); const makeFaunaRequest = container.resolve("makeFaunaRequest"); - const secret = await getSecret(); + const secret = await getSecret(argv); const files = reformatFSL(await gatherFSL(argv.dir)); const { version, status, diff } = await makeFaunaRequest({ diff --git a/src/commands/schema/pull.mjs b/src/commands/schema/pull.mjs index 7f69abcd..e1c0cca6 100644 --- a/src/commands/schema/pull.mjs +++ b/src/commands/schema/pull.mjs @@ -52,7 +52,7 @@ async function doPull(argv) { const logger = container.resolve("logger"); const confirm = container.resolve("confirm"); const makeFaunaRequest = container.resolve("makeFaunaRequest"); - const secret = await getSecret(); + const secret = await getSecret(argv); // Get the staged schema status /** @type {{ status: "none" | "pending" | "ready" | "failed", version: string }} */ diff --git a/src/commands/schema/push.mjs b/src/commands/schema/push.mjs index 70fe2b40..6e2929d4 100644 --- a/src/commands/schema/push.mjs +++ b/src/commands/schema/push.mjs @@ -18,7 +18,7 @@ export async function pushSchema(argv) { const gatherFSL = container.resolve("gatherFSL"); const isStagedPush = !argv.active; - const secret = await getSecret(); + const secret = await getSecret(argv); const fslFiles = await gatherFSL(argv.dir); const hasLocalSchema = fslFiles.length > 0; const absoluteDirPath = path.resolve(argv.dir); diff --git a/src/commands/schema/status.mjs b/src/commands/schema/status.mjs index daa3dd77..3ec99943 100644 --- a/src/commands/schema/status.mjs +++ b/src/commands/schema/status.mjs @@ -13,7 +13,7 @@ async function doStatus(argv) { const logger = container.resolve("logger"); const makeFaunaRequest = container.resolve("makeFaunaRequest"); - const secret = await getSecret(); + const secret = await getSecret(argv); const absoluteDirPath = path.resolve(argv.dir); const gatherFSL = container.resolve("gatherFSL"); const fslFiles = await gatherFSL(argv.dir); diff --git a/src/commands/shell.mjs b/src/commands/shell.mjs index d51e24a7..5371bbb0 100644 --- a/src/commands/shell.mjs +++ b/src/commands/shell.mjs @@ -25,7 +25,7 @@ async function shellCommand(argv) { // Fast fail if the database is not queryable const isQueryable = container.resolve("isQueryable"); - await isQueryable({ ...argv, secret: await getSecret() }); + await isQueryable({ ...argv, secret: await getSecret(argv) }); const logger = container.resolve("logger"); let completionPromise; @@ -176,7 +176,7 @@ async function buildCustomEval(argv) { let res; try { - const secret = await getSecret(); + const secret = await getSecret(argv); const { color, timeout, typecheck, url } = argv; res = await runQueryFromString(cmd, { diff --git a/src/lib/auth/credentials.mjs b/src/lib/auth/credentials.mjs index 634d25bc..b19a25bd 100644 --- a/src/lib/auth/credentials.mjs +++ b/src/lib/auth/credentials.mjs @@ -8,11 +8,7 @@ import { DatabaseKeys } from "./databaseKeys.mjs"; const validateCredentialArgs = (argv) => { const logger = container.resolve("logger"); - const illegalArgCombos = [ - ["accountKey", "secret", isLocal], - ["secret", "database", isLocal], - ["secret", "role", isLocal], - ]; + const illegalArgCombos = [["accountKey", "secret", isLocal]]; for (const [first, second, conditional] of illegalArgCombos) { if (argv[first] && argv[second] && !conditional(argv)) { throw new ValidationError( @@ -67,6 +63,17 @@ export class Credentials { }); this.accountKeys.key = accountKey; } + + /** + * Gets a secret for the current credentials. + * @return {Promise} the secret + */ + async getSecret() { + if (!this.databaseKeys.key) { + return await this.databaseKeys.getOrRefreshKey(); + } + return this.databaseKeys.key; + } } /** diff --git a/src/lib/fauna-client.mjs b/src/lib/fauna-client.mjs index b8d1b53e..40d8df63 100644 --- a/src/lib/fauna-client.mjs +++ b/src/lib/fauna-client.mjs @@ -18,12 +18,13 @@ export const FQL_DIAGNOSTIC_REGEX = /^(\s{2,}\|)|(\s*\d{1,}\s\|)/; * Gets a secret for the current credentials. * @return {Promise} the secret */ -export async function getSecret() { - const credentials = container.resolve("credentials"); - if (!credentials.databaseKeys.key) { - return await credentials.databaseKeys.getOrRefreshKey(); +export async function getSecret(argv) { + if (argv.secret) { + return argv.secret; } - return credentials.databaseKeys.key; + + const credentials = container.resolve("credentials"); + return await credentials.getSecret(); } export const retryInvalidCredsOnce = async (initialSecret, fn) => { diff --git a/src/lib/middleware.mjs b/src/lib/middleware.mjs index 98a9a467..51951466 100644 --- a/src/lib/middleware.mjs +++ b/src/lib/middleware.mjs @@ -9,7 +9,7 @@ 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 { redact, redactedStringify } from "./formatting/redact.mjs"; import { QUERY_OPTIONS } from "./options.mjs"; const LOCAL_URL = "http://0.0.0.0:8443"; const LOCAL_SECRET = "secret"; @@ -131,16 +131,7 @@ function applyLocalToUrl(argv) { function applyLocalToSecret(argv) { const logger = container.resolve("logger"); if (!argv.secret && isLocal(argv)) { - if (argv.role && argv.database) { - argv.secret = `${LOCAL_SECRET}:${argv.database}:${argv.role}`; - } else if (argv.role) { - argv.secret = `${LOCAL_SECRET}:${argv.role}`; - } else if (argv.database) { - // no role - argv.secret = `${LOCAL_SECRET}:${argv.database}:admin`; - } else { - argv.secret = LOCAL_SECRET; - } + argv.secret = LOCAL_SECRET; logger.debug( `Set secret to '${argv.secret}' as --local was given, --secret was not, \ --database was ${argv.database ? `'${argv.database}'` : "not"}, and --role \ @@ -152,6 +143,42 @@ was ${argv.role ? `'${argv.role}'` : "not"}}`, return argv; } +/** + * Mutates argv.secret appropriately when --database and/or --role are + * provided along with --secret. + * @param {import('yargs').Arguments} argv + * @returns {import('yargs').Arguments} + */ +export function scopeSecret(argv) { + const logger = container.resolve("logger"); + if (argv.secret) { + if (argv.database) { + // If --database path is provided with --secret, scope the secret. + // A default role must be provided. + const role = argv.role || "admin"; + const debuggableSecret = `${redact(argv.secret)}:${argv.database}:${role}`; + argv.secret = `${argv.secret}:${argv.database}:${role}`; + + logger.debug( + `Applying scope to secret '${debuggableSecret}', since --database was '${argv.database}' ${argv.role ? `with --role '${argv.role}'` : "with default role 'admin'"}`, + "argv", + argv, + ); + } else if (argv.role) { + // If --role is provided with --secret, scope the secret to the role + const debuggableSecret = `${redact(argv.secret)}:${argv.role}`; + argv.secret = `${argv.secret}:${argv.role}`; + + logger.debug( + `Applying scope to secret '${debuggableSecret}', since --role was '${argv.role}'"`, + "argv", + argv, + ); + } + } + return argv; +} + /** * Mutates argv.include appropriately for query options * @param {Object} argv diff --git a/src/lib/options.mjs b/src/lib/options.mjs index 41cd089f..09f93740 100644 --- a/src/lib/options.mjs +++ b/src/lib/options.mjs @@ -30,7 +30,7 @@ export const ACCOUNT_OPTIONS = { role: { alias: "r", type: "string", - description: "Role used to run the command. Can't be used with --secret.", + description: "Role used to run the command.", group: "API:", }, }; @@ -67,8 +67,7 @@ export const CORE_OPTIONS = { }, secret: { type: "string", - description: - "Secret used for authentication. Can't be used with --database or --role.", + description: "Secret used for authentication.", required: false, group: "API:", }, diff --git a/src/lib/schema.mjs b/src/lib/schema.mjs index 44a2ef3d..3c9fc4fd 100644 --- a/src/lib/schema.mjs +++ b/src/lib/schema.mjs @@ -181,7 +181,7 @@ export async function getAllSchemaFileContents( const promises = []; /** @type Record */ const fileContentCollection = {}; - const secret = await getSecret(); + const secret = await getSecret(argv); const params = new URLSearchParams({ version: version, diff --git a/test/commands/database/database.mjs b/test/commands/database/database.mjs deleted file mode 100644 index 6ac4027d..00000000 --- a/test/commands/database/database.mjs +++ /dev/null @@ -1,48 +0,0 @@ -// @ts-check - -import { expect } from "chai"; -import chalk from "chalk"; - -import { builtYargs, run } from "../../../src/cli.mjs"; -import { setupTestContainer as setupContainer } from "../../../src/config/setup-test-container.mjs"; - -describe("database", () => { - let container, logger; - - beforeEach(() => { - // reset the container before each test - container = setupContainer(); - logger = container.resolve("logger"); - }); - - [ - { - command: - "database create --name 'name' --secret 'secret' --database 'database'", - message: - "Cannot use both the '--secret' and '--database' options together. Please specify only one.", - }, - { - command: - "database delete --name 'name' --secret 'secret' --database 'database'", - message: - "Cannot use both the '--secret' and '--database' options together. Please specify only one.", - }, - { - command: "database list --secret 'secret' --database 'database'", - message: - "Cannot use both the '--secret' and '--database' options together. Please specify only one.", - }, - ].forEach(({ message, command }) => { - it(`requires a ${message}`, async () => { - try { - await run(command, container); - } catch (e) {} - - expect(logger.stderr).to.have.been.calledWith( - `${chalk.reset(await builtYargs.getHelp())}\n\n${chalk.red(message)}`, - ); - expect(container.resolve("parseYargs")).to.have.been.calledOnce; - }); - }); -}); diff --git a/test/lib/middleware.mjs b/test/lib/middleware.mjs index 83b9990f..567cf8ba 100644 --- a/test/lib/middleware.mjs +++ b/test/lib/middleware.mjs @@ -2,18 +2,13 @@ import { expect } from "chai"; -import { setupTestContainer } from "../../src/config/setup-test-container.mjs"; -import { applyLocalArg } from "../../src/lib/middleware.mjs"; +import { applyLocalArg, scopeSecret } from "../../src/lib/middleware.mjs"; describe("middlewares", function () { describe("applyLocalArg", function () { /** @type {import('yargs').Arguments & { url?: string, secret?: string }} */ const baseArgv = { _: [], $0: "", verboseComponent: [] }; - beforeEach(() => { - setupTestContainer(); - }); - it("should set url to 0.0.0.0:8443 when --local is true and no url provided", function () { const argv = { ...baseArgv, local: true }; applyLocalArg(argv); @@ -41,27 +36,32 @@ describe("middlewares", function () { expect(argv.url).to.equal("http://0.0.0.0:8443"); expect(argv.secret).to.equal("custom-secret"); }); + }); + + describe("scopeSecret", function () { + /** @type {import('yargs').Arguments & { secret?: string, database?: string, role?: string }} */ + const baseArgv = { _: [], $0: "", verboseComponent: [] }; it("should set secret with database and role when both provided", function () { const argv = { ...baseArgv, - local: true, + secret: "secret", database: "mydb", role: "myrole", }; - applyLocalArg(argv); + scopeSecret(argv); expect(argv.secret).to.equal("secret:mydb:myrole"); }); it("should set secret with role only when only role provided", function () { - const argv = { ...baseArgv, local: true, role: "myrole" }; - applyLocalArg(argv); + const argv = { ...baseArgv, secret: "secret", role: "myrole" }; + scopeSecret(argv); expect(argv.secret).to.equal("secret:myrole"); }); it("should set secret with database and admin when only database provided", function () { - const argv = { ...baseArgv, local: true, database: "mydb" }; - applyLocalArg(argv); + const argv = { ...baseArgv, secret: "secret", database: "mydb" }; + scopeSecret(argv); expect(argv.secret).to.equal("secret:mydb:admin"); }); }); From 36461e275a9911af2588c0cd768c1976b0be05d4 Mon Sep 17 00:00:00 2001 From: Paul Paterson Date: Fri, 17 Jan 2025 09:32:21 -0500 Subject: [PATCH 2/2] Update formatting for errors and query info (#563) * refactor formatQueryInfo into fauna module * pipe include option through to error formatting * update v4 and v10 error responses * revert dep on fauna * fix headers and existing tests * use includes middleware in shell * enable query info for v4 queries * add tests and fix linting * tweak args * formatting --- src/commands/database/create.mjs | 1 + src/commands/database/delete.mjs | 1 + src/commands/database/list.mjs | 6 +- src/commands/query.mjs | 28 +++-- src/commands/shell.mjs | 11 +- src/config/setup-test-container.mjs | 2 + src/lib/fauna-client.mjs | 118 +++----------------- src/lib/fauna.mjs | 160 +++++++++++++++++++++++++--- src/lib/faunadb.mjs | 92 +++++++++++----- test/commands/query/v10.mjs | 81 +++++++++++++- test/commands/query/v4.mjs | 53 ++++++++- test/helpers.mjs | 27 ++++- test/lib/fauna-client.mjs | 30 ------ test/lib/fauna.mjs | 32 +++++- test/lib/faunadb.mjs | 2 + 15 files changed, 441 insertions(+), 203 deletions(-) delete mode 100644 test/lib/fauna-client.mjs diff --git a/src/commands/database/create.mjs b/src/commands/database/create.mjs index 61720ab9..5d3cfa32 100644 --- a/src/commands/database/create.mjs +++ b/src/commands/database/create.mjs @@ -47,6 +47,7 @@ async function createDatabase(argv) { faunaToCommandError({ err: e, color: argv.color, + include: argv.include, handler: (err) => { if (err instanceof ServiceError && err.code === "constraint_failure") { const cf = err.constraint_failures; diff --git a/src/commands/database/delete.mjs b/src/commands/database/delete.mjs index ccafaac3..3acd83b7 100644 --- a/src/commands/database/delete.mjs +++ b/src/commands/database/delete.mjs @@ -33,6 +33,7 @@ async function deleteDatabase(argv) { faunaToCommandError({ err, color: argv.color, + include: argv.include, handler: (err) => { if (err instanceof ServiceError && err.code === "document_not_found") { throw new CommandError( diff --git a/src/commands/database/list.mjs b/src/commands/database/list.mjs index e82a9113..a119b1b7 100644 --- a/src/commands/database/list.mjs +++ b/src/commands/database/list.mjs @@ -30,7 +30,11 @@ async function listDatabasesWithSecret(argv) { }); return res.data; } catch (e) { - return faunaToCommandError({ err: e, color: argv.color }); + return faunaToCommandError({ + err: e, + color: argv.color, + include: argv.include, + }); } } diff --git a/src/commands/query.mjs b/src/commands/query.mjs index 7293a09b..47c1bcd8 100644 --- a/src/commands/query.mjs +++ b/src/commands/query.mjs @@ -82,22 +82,22 @@ async function queryCommand(argv) { validateDatabaseOrSecret(argv); validate(argv); + const secret = await getSecret(argv); + const { + url, + timeout, + typecheck, + apiVersion, + performanceHints, + color, + include, + } = argv; + // resolve the input const expression = resolveInput(argv); // get the query handler and run the query try { - const secret = await getSecret(argv); - const { - url, - timeout, - typecheck, - apiVersion, - performanceHints, - color, - include, - } = argv; - // If we're writing to a file, don't colorize the output regardless of the user's preference const useColor = argv.output || !isTTY() ? false : color; @@ -115,9 +115,7 @@ async function queryCommand(argv) { color: useColor, }); - // If any query info should be displayed, print to stderr. - // This is only supported in v10. - if (include.length > 0 && apiVersion === "10") { + if (include.length > 0) { const queryInfo = formatQueryInfo(results, { apiVersion, color: useColor, @@ -147,7 +145,7 @@ async function queryCommand(argv) { } const { apiVersion, color } = argv; - throw new CommandError(formatError(err, { apiVersion, color }), { + throw new CommandError(formatError(err, { apiVersion, color, include }), { cause: err, }); } diff --git a/src/commands/shell.mjs b/src/commands/shell.mjs index 5371bbb0..537bdfba 100644 --- a/src/commands/shell.mjs +++ b/src/commands/shell.mjs @@ -8,7 +8,10 @@ import * as esprima from "esprima"; 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"; +import { + resolveIncludeOptions, + validateDatabaseOrSecret, +} from "../lib/middleware.mjs"; import { ACCOUNT_OPTIONS, CORE_OPTIONS, @@ -190,8 +193,7 @@ async function buildCustomEval(argv) { }); // If any query info should be displayed, print to stderr. - // This is only supported in v10. - if (include.length > 0 && apiVersion === "10") { + if (include.length > 0) { const queryInfo = formatQueryInfo(res, { apiVersion, color, @@ -202,7 +204,7 @@ async function buildCustomEval(argv) { } } } catch (err) { - logger.stderr(formatError(err, { apiVersion, color })); + logger.stderr(formatError(err, { apiVersion, color, include })); return cb(null); } @@ -227,6 +229,7 @@ function buildShellCommand(yargs) { .options(DATABASE_PATH_OPTIONS) .options(CORE_OPTIONS) .options(QUERY_OPTIONS) + .middleware(resolveIncludeOptions) .example([ [ "$0 shell --database us/my_db", diff --git a/src/config/setup-test-container.mjs b/src/config/setup-test-container.mjs index c5ce80f3..66e17c5b 100644 --- a/src/config/setup-test-container.mjs +++ b/src/config/setup-test-container.mjs @@ -114,6 +114,7 @@ export function setupTestContainer() { runQuery: stub(), runQueryFromString: stub(), formatQueryResponse: faunaClientV10.formatQueryResponse, + formatQueryInfo: faunaClientV10.formatQueryInfo, formatError: faunaClientV10.formatError, }), faunaClientV4: awilix.asValue({ @@ -121,6 +122,7 @@ export function setupTestContainer() { runQuery: stub(), runQueryFromString: stub(), formatQueryResponse: faunaClientV4.formatQueryResponse, + formatQueryInfo: faunaClientV4.formatQueryInfo, formatError: faunaClientV4.formatError, }), }; diff --git a/src/lib/fauna-client.mjs b/src/lib/fauna-client.mjs index 40d8df63..54d8d255 100644 --- a/src/lib/fauna-client.mjs +++ b/src/lib/fauna-client.mjs @@ -1,18 +1,10 @@ //@ts-check -import stripAnsi from "strip-ansi"; - import { container } from "../config/container.mjs"; import { isUnknownError } from "./errors.mjs"; import { faunaToCommandError } from "./fauna.mjs"; import { faunadbToCommandError } from "./faunadb.mjs"; -import { colorize, Format } from "./formatting/colorize.mjs"; - -/** - * Regex to match the FQL diagnostic line. - * @type {RegExp} - */ -export const FQL_DIAGNOSTIC_REGEX = /^(\s{2,}\|)|(\s*\d{1,}\s\|)/; +import { Format } from "./formatting/colorize.mjs"; /** * Gets a secret for the current credentials. @@ -103,16 +95,17 @@ export const runQueryFromString = (expression, argv) => { * @param {object} opts * @param {string} opts.apiVersion - The API version * @param {boolean} opts.color - Whether to colorize the error + * @param {string[]} opts.include - The query info fields to include * @returns {string} */ -export const formatError = (err, { apiVersion, color }) => { +export const formatError = (err, { apiVersion, color, include }) => { const faunaV4 = container.resolve("faunaClientV4"); const faunaV10 = container.resolve("faunaClientV10"); if (apiVersion === "4") { - return faunaV4.formatError(err, { color }); + return faunaV4.formatError(err, { color, include }); } else { - return faunaV10.formatError(err, { color }); + return faunaV10.formatError(err, { color, include }); } }; @@ -132,11 +125,11 @@ export const isQueryable = async (argv) => { throw err; } - const { color } = argv; + const { color, include } = argv; if (argv.apiVersion === "4") { - faunadbToCommandError({ err, color }); + faunadbToCommandError({ err, color, include }); } else { - faunaToCommandError({ err, color }); + faunaToCommandError({ err, color, include }); } } @@ -153,67 +146,15 @@ export const isQueryable = async (argv) => { * @returns {string} */ export const formatQueryResponse = (res, { apiVersion, color, format }) => { - const faunaV4 = container.resolve("faunaClientV4"); - const faunaV10 = container.resolve("faunaClientV10"); - if (apiVersion === "4") { + const faunaV4 = container.resolve("faunaClientV4"); return faunaV4.formatQueryResponse(res, { format, color }); } else { + const faunaV10 = container.resolve("faunaClientV10"); return faunaV10.formatQueryResponse(res, { format, color }); } }; -/** - * Formats a summary of a query from a fauna - * @param {string} summary - The summary of the query - * @returns {string} - */ -export const formatQuerySummary = (summary) => { - if (!summary || typeof summary !== "string") { - return ""; - } - - try { - const lines = summary.split("\n").map((line) => { - if (!line.match(FQL_DIAGNOSTIC_REGEX)) { - return line; - } - return colorize(line, { format: Format.FQL }); - }); - return lines.join("\n"); - } catch (err) { - const logger = container.resolve("logger"); - logger.debug(`Unable to parse performance hint: ${err}`); - return summary; - } -}; - -const getQueryInfoValue = (response, field) => { - switch (field) { - case "txnTs": - return response.txn_ts; - case "schemaVersion": - return response.schema_version?.toString(); - case "summary": - return response.summary; - case "queryTags": - return response.query_tags; - case "stats": - return response.stats; - default: - return undefined; - } -}; - -const getIncludedQueryInfo = (response, include) => { - const queryInfo = {}; - include.forEach((field) => { - const value = getQueryInfoValue(response, field); - if (value) queryInfo[field] = value; - }); - return queryInfo; -}; - /** * * @param {object} response - The v4 or v10 query response with query info @@ -224,38 +165,11 @@ const getIncludedQueryInfo = (response, include) => { * @returns */ export const formatQueryInfo = (response, { apiVersion, color, include }) => { - if (apiVersion === "4" && include.includes("stats")) { - /** @type {import("faunadb").MetricsResponse} */ - const metricsResponse = response; - const colorized = colorize( - { metrics: metricsResponse.metrics }, - { color, format: Format.YAML }, - ); - - return `${colorized}\n`; - } else if (apiVersion === "10") { - const queryInfoToDisplay = getIncludedQueryInfo(response, include); - - if (Object.keys(queryInfoToDisplay).length === 0) return ""; - - // We colorize the entire query info object as YAML, but then need to - // colorize the diagnostic lines individually. To simplify this, we - // strip the ansi when we're checking if the line is a diagnostic line. - const colorized = colorize(queryInfoToDisplay, { - color, - format: Format.YAML, - }) - .split("\n") - .map((line) => { - if (!stripAnsi(line).match(FQL_DIAGNOSTIC_REGEX)) { - return line; - } - return colorize(line, { format: Format.FQL }); - }) - .join("\n"); - - return `${colorized}\n`; + if (apiVersion === "4") { + const faunaV4 = container.resolve("faunaClientV4"); + return faunaV4.formatQueryInfo(response, { color, include }); + } else { + const faunaV10 = container.resolve("faunaClientV10"); + return faunaV10.formatQueryInfo(response, { color, include }); } - - return ""; }; diff --git a/src/lib/fauna.mjs b/src/lib/fauna.mjs index 37b52ec3..c9993600 100644 --- a/src/lib/fauna.mjs +++ b/src/lib/fauna.mjs @@ -2,9 +2,9 @@ /** * @fileoverview Fauna V10 client utilities for query execution and error handling. */ - import chalk from "chalk"; import { NetworkError, ServiceError } from "fauna"; +import stripAnsi from "strip-ansi"; import { container } from "../config/container.mjs"; import { @@ -14,9 +14,14 @@ import { NETWORK_ERROR_MESSAGE, ValidationError, } from "./errors.mjs"; -import { formatQuerySummary } from "./fauna-client.mjs"; import { colorize, Format } from "./formatting/colorize.mjs"; +/** + * Regex to match the FQL diagnostic line. + * @type {RegExp} + */ +export const FQL_DIAGNOSTIC_REGEX = /^(\s{2,}\|)|(\s*\d{1,}\s\|)/; + /** * Interprets a string as a FQL expression and returns a query. * @param {string} expression - The FQL expression to interpret @@ -120,16 +125,137 @@ export const runQueryFromString = async ({ return runQuery({ query, url, secret, client, options }); }; +/** + * Formats a summary of a query from a fauna + * @param {string} summary - The summary of the query + * @returns {string} + */ +export const formatQuerySummary = (summary) => { + if (!summary || typeof summary !== "string") { + return ""; + } + + try { + const lines = summary.split("\n").map((line) => { + if (!line.match(FQL_DIAGNOSTIC_REGEX)) { + return line; + } + return colorize(line, { format: Format.FQL }); + }); + return lines.join("\n"); + } catch (err) { + const logger = container.resolve("logger"); + logger.debug(`Unable to parse performance hint: ${err}`); + return summary; + } +}; + +const getQueryInfoValue = (response, field) => { + switch (field) { + case "txnTs": + return response.txn_ts; + case "schemaVersion": + return response.schema_version?.toString(); + case "summary": + return response.summary; + case "queryTags": + return response.query_tags; + case "stats": + return response.stats; + default: + return undefined; + } +}; + +const getIncludedQueryInfo = (response, include) => { + const queryInfo = {}; + include.forEach((field) => { + const value = getQueryInfoValue(response, field); + if (value) queryInfo[field] = value; + }); + return queryInfo; +}; + +/** + * + * @param {object} response - The v10 query response with query info + * @param {object} opts + * @param {boolean} opts.color - Whether to colorize the error + * @param {string[]} opts.include - The query info fields to include + * @returns + */ +export const formatQueryInfo = (response, { color, include }) => { + const queryInfoToDisplay = getIncludedQueryInfo(response, include); + + if (Object.keys(queryInfoToDisplay).length === 0) return ""; + + // We colorize the entire query info object as YAML, but then need to + // colorize the diagnostic lines individually. To simplify this, we + // strip the ansi when we're checking if the line is a diagnostic line. + const colorized = colorize(queryInfoToDisplay, { + color, + format: Format.YAML, + }) + .split("\n") + .map((line) => { + if (!stripAnsi(line).match(FQL_DIAGNOSTIC_REGEX)) { + return line; + } + return colorize(line, { format: Format.FQL }); + }) + .join("\n"); + + return `${colorized}\n`; +}; + +const formatServiceError = (err, { color, include }) => { + let message = ""; + // Remove the summary from the include list. We will always show the summary + // under the error, so we don't want to include it in the query info. + const _include = include.filter((i) => i !== "summary"); + const queryInfo = formatQueryInfo(err.queryInfo, { + color, + include: _include, + }); + message = queryInfo === "" ? "" : `${queryInfo}\n`; + + const summary = formatQuerySummary(err.queryInfo?.summary ?? ""); + message += `${chalk.red("The query failed with the following error:")}\n\n${summary}`; + + // err.abort could be `null`, if that's what the user returns + if (err.abort !== undefined) { + const abort = colorize(err.abort, { format: "fql", color }); + message += `\n\n${chalk.red("Abort value:")}\n${abort}`; + } + + if ( + err.constraint_failures !== undefined && + err.constraint_failures.length > 0 + ) { + const contraintFailures = colorize( + JSON.stringify(err.constraint_failures, null, 2), + { + format: "fql", + color, + }, + ); + message += `\n\n${chalk.red("Constraint failures:")}\n${contraintFailures}`; + } + + return message; +}; + /** * Formats a V10 Fauna error for display. * * @param {any} err - An error to format - * @param {object} [opts] - * @param {boolean} [opts.color] - Whether to colorize the error + * @param {object} opts + * @param {boolean} opts.color - Whether to colorize the error + * @param {string[]} opts.include - The query info fields to include * @returns {string} The formatted error message */ -// eslint-disable-next-line no-unused-vars -export const formatError = (err, _opts = {}) => { +export const formatError = (err, opts) => { + let message = ""; // If the error has a queryInfo object with a summary property, we can format it. // Doing this check allows this code to avoid a fauna direct dependency. if ( @@ -137,15 +263,14 @@ export const formatError = (err, _opts = {}) => { typeof err.queryInfo === "object" && typeof err.queryInfo.summary === "string" ) { - // Otherwise, return the summary and fall back to the message. - return `${chalk.red("The query failed with the following error:")}\n\n${formatQuerySummary(err.queryInfo?.summary) ?? err.message}`; + message = formatServiceError(err, opts); + } else if (err.name === "NetworkError") { + message = `${chalk.red("The query failed unexpectedly with the following error:")}\n\n${NETWORK_ERROR_MESSAGE}`; } else { - if (err.name === "NetworkError") { - return `The query failed unexpectedly with the following error:\n\n${NETWORK_ERROR_MESSAGE}`; - } - - return `The query failed unexpectedly with the following error:\n\n${err.message}`; + message = `${chalk.red("The query failed unexpectedly with the following error:")}\n\n${err.message}`; } + + return message; }; /** @@ -171,12 +296,13 @@ export const formatQueryResponse = (res, opts = {}) => { * @param {object} opts * @param {import("fauna").FaunaError} opts.err - The Fauna error to handle * @param {(e: import("fauna").FaunaError) => void} [opts.handler] - Optional error handler to handle and throw in - * @param {boolean} [opts.color] - Whether to colorize the error + * @param {boolean} opts.color - Whether to colorize the error + * @param {string[]} opts.include - The query info fields to include * @throws {Error} Always throws an error with a message based on the error code or handler response * @returns {never} This function always throws an error */ -export const faunaToCommandError = ({ err, handler, color }) => { +export const faunaToCommandError = ({ err, handler, color, include }) => { if (handler) { handler(err); } @@ -190,7 +316,9 @@ export const faunaToCommandError = ({ err, handler, color }) => { case "permission_denied": throw new AuthorizationError({ cause: err }); default: - throw new CommandError(formatError(err, { color }), { cause: err }); + throw new CommandError(formatError(err, { color, include }), { + cause: err, + }); } } diff --git a/src/lib/faunadb.mjs b/src/lib/faunadb.mjs index 5837556f..40057794 100644 --- a/src/lib/faunadb.mjs +++ b/src/lib/faunadb.mjs @@ -2,6 +2,7 @@ import util from "node:util"; import { createContext, runInContext } from "node:vm"; +import chalk from "chalk"; import faunadb from "faunadb"; import { container } from "../config/container.mjs"; @@ -96,15 +97,37 @@ export const runQuery = async ({ } }; +/** + * + * @param {object} response - The v4 query response with query info + * @param {object} opts + * @param {boolean} opts.color - Whether to colorize the error + * @param {string[]} opts.include - The query info fields to include + * @returns + */ +export const formatQueryInfo = (response, { color, include }) => { + if (include && !include.includes("stats")) { + return ""; + } + + const colorized = colorize( + { stats: response.metrics }, + { color, format: Format.YAML }, + ); + + return `${colorized}\n`; +}; + /** * Formats a V4 Fauna error for display. * @param {any} err - An error to format - * @param {object} [opts] - * @param {boolean} [opts.color] - Whether to colorize the error + * @param {object} opts + * @param {boolean} opts.color - Whether to colorize the error + * @param {string[]} opts.include - The query info fields to include * @returns {string} The formatted error message */ -export const formatError = (err, opts = {}) => { - const { color } = opts; +export const formatError = (err, { color, include }) => { + let message = ""; // By doing this we can avoid requiring a faunadb direct dependency if ( @@ -113,31 +136,43 @@ export const formatError = (err, opts = {}) => { typeof err.requestResult.responseContent === "object" && Array.isArray(err.requestResult.responseContent.errors) ) { - const errorPrefix = "The query failed with the following error:\n\n"; - const { errors } = err.requestResult.responseContent; - if (!errors) { - return colorize(errorPrefix + err.message, { color }); - } + // Get query info from the response headers. + const metricsHeaders = [ + "x-compute-ops", + "x-byte-read-ops", + "x-byte-write-ops", + "x-query-time", + "x-txn-retries", + ]; + const metricsResponse = { + metrics: Object.fromEntries( + Array.from(Object.entries(err.requestResult.responseHeaders)) + .filter(([k]) => metricsHeaders.includes(k)) + .map(([k, v]) => [k, parseInt(v)]), + ), + }; + const queryInfo = formatQueryInfo(metricsResponse, { color, include }); - const messages = []; - errors.forEach(({ code, description, position }) => { - messages.push(`${code}: ${description} at ${position.join(", ")}\n`); - }); + message = queryInfo === "" ? "" : `${queryInfo}\n`; - return colorize(errorPrefix + messages.join("\n").trim(), { - color, - }); - } - - const errorPrefix = - "The query failed unexpectedly with the following error:\n\n"; + const subMessages = []; + err.requestResult.responseContent.errors.forEach( + ({ code, description, position }) => { + // add `*query*` so it looks more like the v10 error summaries. + const summary = `${code}: ${description}\nat *query*:${position.join(", ")}`; + let subMessage = `${chalk.red("The query failed with the following error:")}\n\n${summary}`; + subMessages.push(subMessage); + }, + ); - // When fetch fails, we get a TypeError with a "fetch failed" message. - if (err.name === "TypeError" && err.message.includes("fetch failed")) { - return colorize(errorPrefix + NETWORK_ERROR_MESSAGE, { color }); + message += subMessages.join("\n\n"); + } else if (err.name === "TypeError" && err.message.includes("fetch failed")) { + message = `${chalk.red("The query failed unexpectedly with the following error:")}\n\n${NETWORK_ERROR_MESSAGE}`; + } else { + message = `${chalk.red("The query failed unexpectedly with the following error:")}\n\n${err.message}`; } - return colorize(errorPrefix + err.message, { color }); + return message; }; /** @@ -145,10 +180,11 @@ export const formatError = (err, opts = {}) => { * @param {object} opts * @param {any} opts.err - The error to convert * @param {(e: import("fauna").FaunaError) => void} [opts.handler] - Optional error handler to handle and throw in - * @param {boolean} [opts.color] - Whether to colorize the error + * @param {boolean} opts.color - Whether to colorize the error + * @param {string[]} opts.include - The query info fields to include * @returns {void} */ -export const faunadbToCommandError = ({ err, handler, color }) => { +export const faunadbToCommandError = ({ err, handler, color, include }) => { if (handler) { handler(err); } @@ -161,7 +197,9 @@ export const faunadbToCommandError = ({ err, handler, color }) => { throw new AuthorizationError({ cause: err }); case "BadRequest": case "NotFound": - throw new CommandError(formatError(err, { color }), { cause: err }); + throw new CommandError(formatError(err, { color, include }), { + cause: err, + }); default: throw err; } diff --git a/test/commands/query/v10.mjs b/test/commands/query/v10.mjs index 22c9c9f5..851cb3b6 100644 --- a/test/commands/query/v10.mjs +++ b/test/commands/query/v10.mjs @@ -1,7 +1,12 @@ //@ts-check import { expect } from "chai"; -import { NetworkError, ServiceError } from "fauna"; +import { + AbortError, + ConstraintFailureError, + NetworkError, + ServiceError, +} from "fauna"; import sinon from "sinon"; import { run } from "../../../src/cli.mjs"; @@ -62,6 +67,60 @@ describe("query v10", function () { expect(logger.stdout).to.not.be.called; expect(logger.stderr).to.have.been.calledWith(sinon.match(/test query/)); }); + it("can display additional info for abort errors", async function () { + const testSummary = createV10QueryFailure("test query"); + runQueryFromString.rejects( + new AbortError({ + ...testSummary, + error: { ...testSummary.error, abort: `"oops"` }, + }), + ); + + try { + await run(`query "abort('oops')" --secret=foo`, container); + } catch (e) {} + + expect(logger.stdout).to.not.be.called; + expect(logger.stderr).to.have.been.calledWith(sinon.match(/test query/)); + // sample individual output lines to avoid matching with color codes + expect(logger.stderr).to.have.been.calledWith(sinon.match("Abort value:")); + expect(logger.stderr).to.have.been.calledWith(sinon.match('"oops"')); + }); + + it("can display additional info for constraint failure errors", async function () { + const testSummary = createV10QueryFailure("test query"); + runQueryFromString.rejects( + new ConstraintFailureError({ + ...testSummary, + error: { + ...testSummary.error, + constraint_failures: [ + { + paths: [["name"]], + message: "A Collection already exists with the name `foo`", + }, + ], + }, + }), + ); + + try { + await run( + `query "Collection.create({name: 'foo'})" --secret=foo`, + container, + ); + } catch (e) {} + + expect(logger.stdout).to.not.be.called; + expect(logger.stderr).to.have.been.calledWith(sinon.match(/test query/)); + // sample individual output lines to avoid matching with color codes + expect(logger.stderr).to.have.been.calledWith( + sinon.match("Constraint failures:"), + ); + expect(logger.stderr).to.have.been.calledWith( + sinon.match("A Collection already exists with the name `foo`"), + ); + }); it("can set the typecheck option to true", async function () { await run(`query "Database.all()" --typecheck --secret=foo`, container); @@ -169,6 +228,26 @@ describe("query v10", function () { } }); }); + + it("can display query info with an error", async function () { + const testSummary = createV10QueryFailure("test query"); + runQueryFromString.rejects(new ServiceError(testSummary)); + + try { + await run( + `query "Database.all()" --secret=foo --include all`, + container, + ); + } catch (e) {} + + expect(logger.stdout).to.not.be.called; + // sample individual output lines to avoid matching with color codes + expect(logger.stderr).to.have.been.calledWith( + sinon.match("txnTs: 1732664445755210"), + ); + expect(logger.stderr).to.have.been.calledWith(sinon.match("stats:")); + expect(logger.stderr).to.have.been.calledWith(sinon.match(/test query/)); + }); }); it("can handle network errors", async function () { diff --git a/test/commands/query/v4.mjs b/test/commands/query/v4.mjs index 6f0f38b2..beb887b0 100644 --- a/test/commands/query/v4.mjs +++ b/test/commands/query/v4.mjs @@ -98,8 +98,59 @@ describe("query v4", function () { expect(logger.stdout).to.not.be.called; expect(logger.stderr).to.have.been.calledWith( sinon.match( - "invalid argument: Database Ref or Null expected, String provided. at paginate, collections", + "invalid argument: Database Ref or Null expected, String provided.\nat *query*:paginate, collections", ), ); }); + + describe("query info", function () { + it("displays metrics if `--include stats` is used", async function () { + const testResponse = createV4QuerySuccess("test response"); + runQueryFromString.resolves(testResponse); + + await run( + `query "Collection('test')" --apiVersion 4 --secret=foo --include stats`, + container, + ); + + // sample individual output lines to avoid matching with color codes + expect(logger.stderr).to.have.been.calledWith(sinon.match("stats:")); + expect(logger.stderr).to.have.been.calledWith( + sinon.match(" x-byte-read-ops: 0"), + ); + expect(logger.stdout).to.have.been.calledWith( + sinon.match("test response"), + ); + }); + + it("can display query info with an error", async function () { + const testError = createV4QueryFailure({ + position: ["paginate", "collections"], + code: "invalid argument", + description: "Database Ref or Null expected, String provided.", + }); + + // @ts-ignore + runQueryFromString.rejects(testError); + + try { + await run( + `query "Paginate(Collection('x'))" --apiVersion 4 --secret=foo --include stats`, + container, + ); + } catch (e) {} + + expect(logger.stdout).to.not.be.called; + // sample individual output lines to avoid matching with color codes + expect(logger.stderr).to.have.been.calledWith(sinon.match("stats:")); + expect(logger.stderr).to.have.been.calledWith( + sinon.match(" x-byte-read-ops: 0"), + ); + expect(logger.stderr).to.have.been.calledWith( + sinon.match( + "invalid argument: Database Ref or Null expected, String provided.\nat *query*:paginate, collections", + ), + ); + }); + }); }); diff --git a/test/helpers.mjs b/test/helpers.mjs index 2a6b5453..8d55078f 100644 --- a/test/helpers.mjs +++ b/test/helpers.mjs @@ -149,20 +149,31 @@ export const createV10QueryFailure = (summary) => { error: { code: "test_error", message: "test error", - constraint_failures: [], }, httpStatus: 400, summary, + txn_ts: 1732664445755210, + stats: { + compute_ops: 1, + read_ops: 9, + write_ops: 0, + query_time_ms: 15, + contention_retries: 0, + storage_bytes_read: 510, + storage_bytes_write: 0, + rate_limits_hit: [], + attempts: 1, + }, }; }; export const createV4QuerySuccess = (data) => ({ value: data, metrics: { - "x-byte-read-ops": 8, + "x-byte-read-ops": 0, "x-byte-write-ops": 0, - "x-compute-ops": 1, - "x-query-time": 15, + "x-compute-ops": 0, + "x-query-time": 0, "x-txn-retries": 0, }, }); @@ -174,7 +185,13 @@ export const createV4QueryFailure = (error) => ({ }), responseContent: { errors: [error] }, statusCode: 400, - headers: {}, + responseHeaders: { + "x-byte-read-ops": 0, + "x-byte-write-ops": 0, + "x-compute-ops": 0, + "x-query-time": 0, + "x-txn-retries": 0, + }, method: "POST", path: "/", query: "", diff --git a/test/lib/fauna-client.mjs b/test/lib/fauna-client.mjs deleted file mode 100644 index fb71603c..00000000 --- a/test/lib/fauna-client.mjs +++ /dev/null @@ -1,30 +0,0 @@ -import { expect } from "chai"; - -import { FQL_DIAGNOSTIC_REGEX } from "../../src/lib/fauna-client.mjs"; - -describe("FQL_DIAGNOSTIC_REGEX", () => { - const validLines = ["1 |", "12 |", "123 |", " |", " |", " |", " 1 |"]; - - const invalidLines = [ - "normal text", - "1 |", - "| invalid", - "abc |", - "|", - "1|", - " | ", - "text | more", - ]; - - validLines.forEach((line) => { - it(`should match diagnostic line: "${line}"`, () => { - expect(line).to.match(FQL_DIAGNOSTIC_REGEX); - }); - }); - - invalidLines.forEach((line) => { - it(`should not match non-diagnostic line: "${line}"`, () => { - expect(line).to.not.match(FQL_DIAGNOSTIC_REGEX); - }); - }); -}); diff --git a/test/lib/fauna.mjs b/test/lib/fauna.mjs index 993256f4..a3da6e05 100644 --- a/test/lib/fauna.mjs +++ b/test/lib/fauna.mjs @@ -7,7 +7,10 @@ import { CommandError, NETWORK_ERROR_MESSAGE, } from "../../src/lib/errors.mjs"; -import { faunaToCommandError } from "../../src/lib/fauna.mjs"; +import { + faunaToCommandError, + FQL_DIAGNOSTIC_REGEX, +} from "../../src/lib/fauna.mjs"; describe("faunaToCommandError", () => { it("should convert unauthorized ServiceError to AuthenticationError", () => { @@ -118,3 +121,30 @@ describe("faunaToCommandError", () => { } }); }); + +describe("FQL_DIAGNOSTIC_REGEX", () => { + const validLines = ["1 |", "12 |", "123 |", " |", " |", " |", " 1 |"]; + + const invalidLines = [ + "normal text", + "1 |", + "| invalid", + "abc |", + "|", + "1|", + " | ", + "text | more", + ]; + + validLines.forEach((line) => { + it(`should match diagnostic line: "${line}"`, () => { + expect(line).to.match(FQL_DIAGNOSTIC_REGEX); + }); + }); + + invalidLines.forEach((line) => { + it(`should not match non-diagnostic line: "${line}"`, () => { + expect(line).to.not.match(FQL_DIAGNOSTIC_REGEX); + }); + }); +}); diff --git a/test/lib/faunadb.mjs b/test/lib/faunadb.mjs index 6e55e697..c7dd7c57 100644 --- a/test/lib/faunadb.mjs +++ b/test/lib/faunadb.mjs @@ -39,6 +39,7 @@ describe("faunadbToCommandError", () => { responseContent: { errors: [], }, + responseHeaders: {}, }); expect(() => faunadbToCommandError({ err: faunaError })).to.throw( @@ -51,6 +52,7 @@ describe("faunadbToCommandError", () => { responseContent: { errors: [], }, + responseHeaders: {}, }); expect(() => faunadbToCommandError({ err: faunaError })).to.throw(