From a7227a3290a24082acde2ab6a94126d6ddd52c14 Mon Sep 17 00:00:00 2001 From: Cleve Stuart <90649124+cleve-fauna@users.noreply.github.com> Date: Thu, 5 Dec 2024 14:04:24 -0800 Subject: [PATCH] Create Key (#468) Create a key --------- Co-authored-by: Ashton Eby Co-authored-by: Ashton Eby --- package-lock.json | 10 +++ package.json | 1 + src/cli.mjs | 2 +- src/commands/key.mjs | 66 ------------------ src/commands/key/create.mjs | 77 +++++++++++++++++++++ src/commands/key/key.mjs | 18 +++++ src/lib/auth/databaseKeys.mjs | 1 + src/lib/fauna-account-client.mjs | 14 ++-- src/lib/fauna.mjs | 13 +--- src/lib/faunadb.mjs | 10 +-- src/lib/middleware.mjs | 1 + src/lib/misc.mjs | 17 +++++ test/key/create.mjs | 114 +++++++++++++++++++++++++++++++ 13 files changed, 254 insertions(+), 90 deletions(-) delete mode 100644 src/commands/key.mjs create mode 100644 src/commands/key/create.mjs create mode 100644 src/commands/key/key.mjs create mode 100644 test/key/create.mjs diff --git a/package-lock.json b/package-lock.json index 9868f52e..d5bed213 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "faunadb": "^4.5.4", "inquirer": "^12.0.0", "json-colorizer": "^3.0.1", + "luxon": "^3.5.0", "open": "10.1.0", "update-notifier": "^7.3.1", "yaml": "^2.6.1", @@ -2270,6 +2271,15 @@ "tslib": "^2.0.3" } }, + "node_modules/luxon": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.5.0.tgz", + "integrity": "sha512-rh+Zjr6DNfUYR3bPwJEnuwDdqMbxZW7LOQfUN4B54+Cl+0o5zaU9RJ6bcidfDtC1cWCZXQ+nvX8bf6bAji37QQ==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/md5": { "version": "2.3.0", "dev": true, diff --git a/package.json b/package.json index db55c0b3..5564df80 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "faunadb": "^4.5.4", "inquirer": "^12.0.0", "json-colorizer": "^3.0.1", + "luxon": "^3.5.0", "open": "10.1.0", "update-notifier": "^7.3.1", "yaml": "^2.6.1", diff --git a/src/cli.mjs b/src/cli.mjs index ac327656..74f0a631 100644 --- a/src/cli.mjs +++ b/src/cli.mjs @@ -5,7 +5,7 @@ import chalk from "chalk"; import yargs from "yargs"; import databaseCommand from "./commands/database/database.mjs"; -import keyCommand from "./commands/key.mjs"; +import keyCommand from "./commands/key/key.mjs"; import loginCommand from "./commands/login.mjs"; import queryCommand from "./commands/query.mjs"; import schemaCommand from "./commands/schema/schema.mjs"; diff --git a/src/commands/key.mjs b/src/commands/key.mjs deleted file mode 100644 index 306fdad0..00000000 --- a/src/commands/key.mjs +++ /dev/null @@ -1,66 +0,0 @@ -//@ts-check - -import { container } from "../cli.mjs"; -import { FaunaAccountClient } from "../lib/fauna-account-client.mjs"; - -async function createKey(argv) { - const logger = container.resolve("logger"); - const AccountClient = new FaunaAccountClient(); - const { database, role, ttl } = argv; - const databaseKey = await AccountClient.createKey({ - path: database, - role, - ttl, - }); - logger.stdout( - `Created key for ${database} with role ${role}\n${JSON.stringify(databaseKey)}`, - ); -} - -function buildKeyCommand(yargs) { - return yargs - .positional("method", { - type: "string", - choices: ["create", "list", "delete"], - describe: "choose a method to interact with your databases", - }) - .options({ - url: { - type: "string", - description: "the Fauna URL to query", - default: "https://db.fauna.com:443", - }, - role: { - alias: "r", - type: "string", - describe: "The role to assign to the key", - }, - }) - .example([["$0 key create"]]); -} - -function keyHandler(argv) { - const method = argv.method; - const logger = container.resolve("logger"); - - switch (method) { - case "create": - createKey(argv); - break; - case "delete": - logger.stdout("Deleting key..."); - break; - case "list": - logger.stdout("Listing keys..."); - break; - default: - break; - } -} - -export default { - command: "key ", - description: "Manage a database's keys.", - builder: buildKeyCommand, - handler: keyHandler, -}; diff --git a/src/commands/key/create.mjs b/src/commands/key/create.mjs new file mode 100644 index 00000000..892f44e2 --- /dev/null +++ b/src/commands/key/create.mjs @@ -0,0 +1,77 @@ +import { DateTime, Settings } from "luxon"; + +import { container } from "../../cli.mjs"; +import { FaunaAccountClient } from "../../lib/fauna-account-client.mjs"; +import { formatObject } from "../../lib/misc.mjs"; + +Settings.defaultZone = "utc"; + +async function createKey(argv) { + if (argv.secret) { + return createKeyWithSecret(argv); + } + return createKeyWithAccountApi(argv); +} + +async function createKeyWithSecret(/*argv*/) { + const logger = container.resolve("logger"); + logger.stderr("TODO"); +} + +async function createKeyWithAccountApi(argv) { + const accountClient = new FaunaAccountClient(); + const { database, keyRole, ttl, name } = argv; + const databaseKey = await accountClient.createKey({ + path: database, + role: keyRole, + ttl, + name, + }); + const { path: db, ...rest } = databaseKey; + container + .resolve("logger") + .stdout(formatObject({ ...rest, database: db }, argv)); +} + +function buildCreateCommand(yargs) { + return yargs + .options({ + name: { + type: "string", + required: false, + description: "The name of the key", + }, + ttl: { + type: "string", + required: false, + description: + "The time-to-live for the key. Provide as an ISO 8601 date time string.", + }, + keyRole: { + type: "string", + required: true, + description: "The role to assign to the key; e.g. admin", + }, + }) + .check((argv) => { + if (argv.ttl && !DateTime.fromISO(argv.ttl).isValid) { + throw new Error( + `Invalid ttl '${argv.ttl}'. Provide as an ISO 8601 date time string.`, + ); + } + if (argv.database === undefined && argv.secret === undefined) { + throw new Error( + "You must provide at least one of: --database, --secret, --local.", + ); + } + return true; + }) + .help("help", "show help"); +} + +export default { + command: "create", + describe: "Create a key for a database", + builder: buildCreateCommand, + handler: createKey, +}; diff --git a/src/commands/key/key.mjs b/src/commands/key/key.mjs new file mode 100644 index 00000000..22f934b2 --- /dev/null +++ b/src/commands/key/key.mjs @@ -0,0 +1,18 @@ +//@ts-check + +import { yargsWithCommonQueryOptions } from "../../lib/command-helpers.mjs"; +import createCommand from "./create.mjs"; + +function buildKeyCommand(yargs) { + return yargsWithCommonQueryOptions(yargs) + .command(createCommand) + .demandCommand() + .help("help", "show help"); +} + +export default { + command: "key ", + describe: "Create and manage database keys", + builder: buildKeyCommand, + handler: () => {}, // eslint-disable-line no-empty-function +}; diff --git a/src/lib/auth/databaseKeys.mjs b/src/lib/auth/databaseKeys.mjs index c65ef768..521c3e1b 100644 --- a/src/lib/auth/databaseKeys.mjs +++ b/src/lib/auth/databaseKeys.mjs @@ -120,6 +120,7 @@ export class DatabaseKeys { const newSecret = await accountClient.createKey({ path, role, + name: "System generated shell key", ttl: new Date(expiration).toISOString(), }); this.keyStore.save(this.keyName, { diff --git a/src/lib/fauna-account-client.mjs b/src/lib/fauna-account-client.mjs index b0e2eca5..e8566859 100644 --- a/src/lib/fauna-account-client.mjs +++ b/src/lib/fauna-account-client.mjs @@ -3,6 +3,8 @@ import { container } from "../cli.mjs"; import { InvalidCredsError } from "./misc.mjs"; +// const KEY_TTL_DEFAULT_MS = 1000 * 60 * 60 * 24; + /** * Class representing a client for interacting with the Fauna account API. */ @@ -198,20 +200,20 @@ export class FaunaAccountClient { * @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} params.ttl - ISO String for the key's expiration time + * @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 }) { - const TTL_DEFAULT_MS = 1000 * 60 * 60 * 24; - return await this.retryableAccountRequest({ + async createKey({ path, role, ttl, name }) { + return this.retryableAccountRequest({ method: "POST", path: "/databases/keys", body: JSON.stringify({ role, path: FaunaAccountClient.standardizeRegion(path), - ttl: ttl || new Date(Date.now() + TTL_DEFAULT_MS).toISOString(), - name: "System generated shell key", + ttl, + name, }), secret: this.accountKeys.key, }); diff --git a/src/lib/fauna.mjs b/src/lib/fauna.mjs index 54cdfb8c..8b4449bd 100644 --- a/src/lib/fauna.mjs +++ b/src/lib/fauna.mjs @@ -13,7 +13,7 @@ import { import { container } from "../cli.mjs"; import { ValidationError } from "./command-helpers.mjs"; -import { formatFullErrorForShell, formatObjectForShell } from "./misc.mjs"; +import { formatFullErrorForShell, formatObject } from "./misc.mjs"; /** * Interprets a string as a FQL expression and returns a query. @@ -159,18 +159,11 @@ export const formatError = (err, opts = {}) => { * @returns {string} The formatted response */ export const formatQueryResponse = (res, opts = {}) => { - const { extra, json, color } = opts; + const { extra } = opts; // If extra is set, return the full response object. const data = extra ? res : res.data; - - // If json is set, return the response as a JSON string. - if (json) { - return JSON.stringify(data); - } - - // Otherwise, return the response as a pretty-printed JSON string. - return formatObjectForShell(data, { color }); + return formatObject(data, opts); }; /** diff --git a/src/lib/faunadb.mjs b/src/lib/faunadb.mjs index fd14d1cd..3678f512 100644 --- a/src/lib/faunadb.mjs +++ b/src/lib/faunadb.mjs @@ -2,7 +2,7 @@ import { createContext, runInContext } from "node:vm"; import { container } from "../cli.mjs"; -import { formatFullErrorForShell, formatObjectForShell } from "./misc.mjs"; +import { formatFullErrorForShell, formatObject } from "./misc.mjs"; /** * Creates a V4 Fauna client. @@ -129,13 +129,9 @@ export const formatError = (err, opts = {}) => { * @returns {string} The formatted response */ export const formatQueryResponse = (res, opts = {}) => { - const { extra, json, color } = opts; + const { extra } = opts; const data = extra ? res : res.value; - if (json) { - return JSON.stringify(data); - } - - return formatObjectForShell(data, { color }); + return formatObject(data, opts); }; /** diff --git a/src/lib/middleware.mjs b/src/lib/middleware.mjs index 5ca6d19f..ff437eb4 100644 --- a/src/lib/middleware.mjs +++ b/src/lib/middleware.mjs @@ -100,4 +100,5 @@ export function applyLocalArg(argv) { argv, ); } + return argv; } diff --git a/src/lib/misc.mjs b/src/lib/misc.mjs index c372fa84..c5a678cc 100644 --- a/src/lib/misc.mjs +++ b/src/lib/misc.mjs @@ -38,6 +38,23 @@ export function isTTY() { return process.stdout.isTTY; } +/** + * Formats an object for display. + * @param {any} obj - The object to format + * @param {object} [opts] - Options + * @param {boolean} [opts.json] - Whether to return a JSON string with no pretty-printing + * @returns {string} The formatted object + */ +export function formatObject(obj, opts = {}) { + const { json } = opts; + // if json is set return a JSON string + if (json) { + return JSON.stringify(obj); + } + // Otherwise, return a pretty-printed JSON string + return formatObjectForShell(obj, opts); +} + /** * Formats an object for display in the shell. * @param {any} obj - The object to format diff --git a/test/key/create.mjs b/test/key/create.mjs new file mode 100644 index 00000000..a21d3745 --- /dev/null +++ b/test/key/create.mjs @@ -0,0 +1,114 @@ +//@ts-check + +import { expect } from "chai"; +import sinon from "sinon"; + +import { run } from "../../src/cli.mjs"; +import { setupTestContainer as setupContainer } from "../../src/config/setup-test-container.mjs"; +import { formatObjectForShell } from "../../src/lib/misc.mjs"; +import { mockAccessKeysFile } from "../helpers.mjs"; + +describe("key create", () => { + let container, fs, logger, makeAccountRequest; + + beforeEach(() => { + // reset the container before each test + container = setupContainer(); + fs = container.resolve("fs"); + logger = container.resolve("logger"); + makeAccountRequest = container.resolve("makeAccountRequest"); + }); + + async function runCommand(command) { + return run(command, container); + } + + [ + { + command: "key create --keyRole admin", + expected: + "You must provide at least one of: --database, --secret, --local.", + }, + { + command: "key create --database us-std", + expected: "Missing required argument: keyRole", + }, + { + command: "key create --database us-std --ttl taco --keyRole admin", + expected: "Invalid ttl 'taco'. Provide as an ISO 8601 date time string.", + }, + ].forEach(({ command, expected }) => { + it("Provides clear error when invalid args are provided", async () => { + try { + await runCommand(command); + } catch (e) {} + + expect(logger.stderr).to.have.been.calledWith(sinon.match(expected)); + expect(container.resolve("parseYargs")).to.have.been.calledOnce; + }); + }); + + describe("using a user", () => { + [ + [ + "key create --database us-std/test --keyRole admin --ttl '3000-01-01T00:00:00Z' --name taco", + true, + true, + ], + [ + "key create --database us-std/test --keyRole admin --ttl '3000-01-01T00:00:00Z' --no-color --name taco", + true, + false, + ], + [ + "key create --database us-std/test --keyRole admin --ttl '3000-01-01T00:00:00Z' --json --name taco", + false, + true, + ], + ].forEach(([command, prettyPrinted, color]) => { + it("Can call the create key API", async () => { + mockAccessKeysFile({ fs }); + const stubbedResponse = { + path: "us-std/test", + ttl: "3000-01-01T00:00:00Z", + secret: "foo", + role: "admin", + }; + const { path: database, ...rest } = stubbedResponse; + const expected = { ...rest, database }; + makeAccountRequest.resolves(stubbedResponse); + await runCommand(command); + expect(makeAccountRequest).to.have.been.calledOnceWith({ + method: "POST", + path: "/databases/keys", + body: JSON.stringify({ + role: "admin", + path: "us-std/test", + ttl: "3000-01-01T00:00:00Z", + name: "taco", + }), + secret: sinon.match.string, + }); + expect(logger.stdout).to.have.been.calledOnceWith( + prettyPrinted + ? formatObjectForShell(expected, { color }) + : JSON.stringify(expected), + ); + }); + }); + }); + + describe("using --secret", () => { + it("Prints out a TODO", async () => { + await runCommand("key create --secret secret --keyRole admin"); + expect(logger.stderr).to.have.been.calledWith(sinon.match("TODO")); + }); + }); + + describe("using --local", () => { + it("Prints out a TODO", async () => { + await runCommand("key create --local --keyRole admin"); + expect(logger.stderr).to.have.been.calledWith(sinon.match("TODO")); + }); + }); +});