Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

persist secrets #417

Merged
merged 18 commits into from
Nov 20, 2024
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@
"pretest": "npm run format && npm run lint",
"lint": "eslint . --fix",
"test": "mocha --recursive ./test --require ./test/mocha-root-hooks.mjs --reporter spec --reporter mocha-junit-reporter",
"test:local": "mocha --recursive ./test --require ./test/mocha-root-hooks.mjs",
"test:local": "mocha --recursive ./test/authNZ.mjs --require ./test/mocha-root-hooks.mjs",
mwilde345 marked this conversation as resolved.
Show resolved Hide resolved
"build": "npm run build:app && npm run build:sea",
"build:app": "esbuild --bundle ./src/user-entrypoint.mjs --platform=node --outfile=./dist/cli.cjs --format=cjs --inject:./sea/import-meta-url.js --define:import.meta.url=importMetaUrl",
"build:sea": "node ./sea/build.cjs",
Expand Down
7 changes: 6 additions & 1 deletion src/cli.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,12 @@ function buildYargs(argvInput) {
alias: "d",
type: "string",
description: "a database path, including region",
// required: true,
},
role: {
alias: "r",
type: "string",
description: "a role",
default: "admin",
mwilde345 marked this conversation as resolved.
Show resolved Hide resolved
},
color: {
description:
Expand Down
9 changes: 4 additions & 5 deletions src/lib/auth/authNZ.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ export function cleanupSecretsFile() {
}

// TODO: account for env var for account key. if profile isn't defined.
async function setAccountKey(profile) {
export async function setAccountKey(profile) {
// Don't leave hanging db secrets that don't match up to stored account keys
cleanupSecretsFile();
const accountCreds = container.resolve("accountCreds");
Expand All @@ -86,7 +86,7 @@ export function getAccountKey(profile) {
const accountCreds = container.resolve("accountCreds");
try {
const creds = accountCreds.get({ key: profile });
return creds.account_key;
return creds.accountKey;
} catch (e) {
if (e instanceof CredsNotFoundError) {
// Throw InvalidCredsError back up to middleware entrypoint to prompt login
Expand Down Expand Up @@ -185,8 +185,7 @@ export async function checkDBKeyRemote(dbKey, url) {
if (result.status === 401) {
return null;
} else {
throw new Error(
`Error contacting fauna [${result.status}]: ${result.error.code}`,
);
const errorCode = result.body?.error?.code || "internal_error";
throw new Error(`Error contacting fauna [${result.status}]: ${errorCode}`);
}
}
1 change: 1 addition & 0 deletions src/lib/fauna-account-client.mjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
//@ts-check

import { container } from "../cli.mjs";
import { InvalidCredsError, UnauthorizedError } from "./misc.mjs";

/**
* Class representing a client for interacting with the Fauna account API.
Expand Down
166 changes: 166 additions & 0 deletions test/authNZ.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import * as awilix from "awilix";
import { expect } from "chai";
import { beforeEach } from "mocha";
import sinon, { stub } from "sinon";

import { run } from "../src/cli.mjs";
import { setupTestContainer as setupContainer } from "../src/config/setup-test-container.mjs";
import { authNZMiddleware, setAccountKey } from "../src/lib/auth/authNZ.mjs";
import { AccountKey, SecretKey } from "../src/lib/file-util.mjs";
import { InvalidCredsError } from "../src/lib/misc.mjs";
import { f } from "./helpers.mjs";

describe("authNZMiddleware", function () {
let container;
let fetch;
let logger;
const validAccessKeyFile =
'{"test-profile": { "accountKey": "valid-account-key", "refreshToken": "valid-refresh-token"}}';
const validSecretKeyFile =
'{"valid-account-key": { "test-db": {"admin": "valid-db-key"}}}';
const mockAccountClient = () => {
return {
whoAmI: stub().resolves(true),
createKey: stub().resolves({ secret: "new-db-key" }),
refreshSession: stub().resolves({
account_key: "new-account-key",
refresh_token: "new-refresh-token",
}),
};
};

beforeEach(() => {
container = setupContainer();
container.register({
accountClient: awilix.asFunction(mockAccountClient).scoped(),
accountCreds: awilix.asClass(AccountKey).scoped(),
secretCreds: awilix.asClass(SecretKey).scoped(),
mwilde345 marked this conversation as resolved.
Show resolved Hide resolved
});
fetch = container.resolve("fetch");
logger = container.resolve("logger");
});

it("should pass through if authRequired is false", async function () {
const argv = { authRequired: false };
const result = await authNZMiddleware(argv);
expect(fetch.called).to.be.false;
expect(result).to.deep.equal(argv);
});

it("should prompt login if InvalidCredsError is thrown", async function () {
const scope = container.createScope();
const argv = { authRequired: true, profile: "test-profile" };
await run("db list", scope);
const exit = scope.resolve("exit");
const accountCreds = scope.resolve("accountCreds");

accountCreds.get = stub().throws(new InvalidCredsError());

await authNZMiddleware(argv);
expect(logger.stderr.args[0][0]).to.include("not signed in or has expired");
expect(logger.stdout.args[0][0]).to.include(
"To sign in, run:\n\nfauna login --profile test-profile\n",
);
mwilde345 marked this conversation as resolved.
Show resolved Hide resolved
expect(exit.calledOnce).to.be.true;
});

it("should refresh session if account key is invalid", async function () {
const argv = { authRequired: true, profile: "test-profile" };
const scope = container.createScope();

await run("db list", scope);
const accountCreds = scope.resolve("accountCreds");
accountCreds.save = stub();

const fs = scope.resolve("fs");
fs.readFileSync.withArgs(sinon.match(/secret_keys/)).returns("{}");
mwilde345 marked this conversation as resolved.
Show resolved Hide resolved
fs.readFileSync
.withArgs(sinon.match(/access_keys/))
.returns(validAccessKeyFile);

const accountClient = scope.resolve("accountClient");
accountClient.whoAmI.onFirstCall().throws(new InvalidCredsError());

await authNZMiddleware(argv);
expect(accountClient.refreshSession.calledOnce).to.be.true;
expect(accountCreds.save.calledOnce).to.be.true;
expect(accountCreds.save.args[0][0].creds).to.deep.equal({
mwilde345 marked this conversation as resolved.
Show resolved Hide resolved
account_key: "new-account-key",
refresh_token: "new-refresh-token",
});
});

describe("Short term DB Keys", () => {
let scope;
let fs;

const argv = {
authRequired: true,
profile: "test-profile",
database: "test-db",
url: "http://localhost",
role: "admin",
};
beforeEach(() => {
scope = container.createScope();
scope.register({
accountCreds: awilix.asClass(AccountKey).scoped(),
secretCreds: awilix.asClass(SecretKey).scoped(),
});
fs = scope.resolve("fs");
fs.readFileSync.callsFake((path) => {
if (path.includes("access_keys")) {
return validAccessKeyFile;
} else {
return validSecretKeyFile;
}
});
});
it("returns existing db key if it's valid", async function () {
await run("db list", scope);

const fetch = scope.resolve("fetch");
const secretCreds = scope.resolve("secretCreds");
fetch.resolves(f(true));
secretCreds.save = stub();

await authNZMiddleware(argv);
// Check that setDBKey was called and secrets were saved
expect(secretCreds.save.called).to.be.false;
});

it("creates a new db key if one doesn't exist", async function () {
await run("db list", scope);

const secretCreds = scope.resolve("secretCreds");
fs.readFileSync.withArgs(sinon.match(/secret_keys/)).returns("{}");

secretCreds.save = stub();

await authNZMiddleware(argv);
// Check that setDBKey was called and secrets were saved
expect(secretCreds.save.called).to.be.true;
expect(secretCreds.save.args[0][0].key).to.equal("valid-account-key");
expect(secretCreds.save.args[0][0].creds).to.deep.equal({
path: "test-db",
role: "admin",
secret: "new-db-key",
});
});

it("should clean up secrets file during setAccountKey", async function () {
await run("db list", scope);

const secretCreds = scope.resolve("secretCreds");
secretCreds.delete = stub();
fs.readFileSync
.withArgs(sinon.match(/secret_keys/))
.returns('{"old-account-key": {"admin": "old-db-key"}}');

await setAccountKey("test-profile");

// Verify the cleanup secrets logic
expect(secretCreds.delete.calledOnce).to.be.true;
});
});
});
4 changes: 2 additions & 2 deletions test/helpers.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ import { join } from "node:path";

// small helper for sinon to wrap your return value
// in the shape fetch would return it from the network
export function f(returnValue) {
return { json: async () => returnValue };
export function f(returnValue, status) {
return { json: async () => returnValue, status: status || 200 };
mwilde345 marked this conversation as resolved.
Show resolved Hide resolved
}

export const commonFetchParams = {
Expand Down
Loading