Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/git-credential-helper.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@qawolf/cli": minor
---

`qawolf auth login` now sets up Git to use your QA Wolf sign-in, so `git clone`, `git fetch` and `git push` work with a workspace's QA Wolf Git URL and do not ask for a password. Login adds one `credential.<host>.helper` entry to your global Git config, prints the file and the lines it added, and changes no other Git setting. `qawolf auth logout` removes that entry. Git setup is skipped when the CLI runs through `npx` or `bunx`.
135 changes: 135 additions & 0 deletions src/commands/auth/gitCredential.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import { describe, expect, it, mock } from "bun:test";

import { authMessages } from "~/core/messages/index.js";
import type { ApiKeyResult } from "~/domains/auth/types.js";

import {
type GitCredentialDeps,
handleGitCredential,
} from "./gitCredential.js";

const qaWolfRequest =
'protocol=https\nhost=app.qawolf.com\nwwwauth[]=Basic realm="QA Wolf Git"\n\n';

function encodeSegment(value: object) {
return Buffer.from(JSON.stringify(value)).toString("base64url");
}

const sessionToken = `${encodeSegment({ alg: "RS256" })}.${encodeSegment({ exp: 2_000_000_000 })}.signature`;

function makeDeps({
credential,
request = qaWolfRequest,
}: {
credential: ApiKeyResult | undefined;
request?: string;
}) {
const written = { error: "", output: "" };
const deps: GitCredentialDeps = {
hostUrl: "https://app.qawolf.com",
readRequest: mock(async () => request),
resolveCredential: mock(async () => credential),
writeError: (message) => {
written.error += message;
},
writeOutput: (output) => {
written.output += output;
},
};
return { deps, written };
}

describe("handleGitCredential", () => {
it("gives Git the browser session and the time it expires", async () => {
const { deps, written } = makeDeps({
credential: {
key: sessionToken,
source: "browser",
workspaceId: undefined,
},
});

await handleGitCredential({ deps, operation: "get" });

expect(written.output).toBe(
`username=qawolf\npassword=${sessionToken}\npassword_expiry_utc=2000000000\n`,
);
expect(written.error).toBe("");
});

it("gives Git a stored user API key without an expiry", async () => {
const { deps, written } = makeDeps({
credential: {
key: "sk_test_user",
source: "keychain",
workspaceId: undefined,
},
});

await handleGitCredential({ deps, operation: "get" });

expect(written.output).toBe("username=qawolf\npassword=sk_test_user\n");
});

it("answers nothing for another host", async () => {
const { deps, written } = makeDeps({
credential: {
key: "sk_test_user",
source: "keychain",
workspaceId: undefined,
},
request: "protocol=https\nhost=github.com\n\n",
});

await handleGitCredential({ deps, operation: "get" });

expect(written).toEqual({ error: "", output: "" });
expect(deps.resolveCredential).not.toHaveBeenCalled();
});

it("tells a signed-out user how to sign in, on stderr only", async () => {
const { deps, written } = makeDeps({ credential: undefined });

await handleGitCredential({ deps, operation: "get" });

expect(written).toEqual({
error: `${authMessages.gitHelper.signedOut}\n`,
output: "",
});
});

it("withholds a team API key, which the Git route rejects", async () => {
const { deps, written } = makeDeps({
credential: {
key: "qawolf_teamkey",
source: "env",
workspaceId: undefined,
},
});

await handleGitCredential({ deps, operation: "get" });

expect(written).toEqual({
error: `${authMessages.gitHelper.teamApiKey}\n`,
output: "",
});
});

it.each(["store", "erase"])(
"leaves Git's %s request alone",
async (operation) => {
const { deps, written } = makeDeps({
credential: {
key: "sk_test_user",
source: "keychain",
workspaceId: undefined,
},
});

await handleGitCredential({ deps, operation });

expect(written).toEqual({ error: "", output: "" });
expect(deps.readRequest).not.toHaveBeenCalled();
},
);
});
49 changes: 49 additions & 0 deletions src/commands/auth/gitCredential.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { text } from "node:stream/consumers";

import { authMessages } from "~/core/messages/index.js";
import { getConfigDir } from "~/core/paths.js";
import { resolveApiKey } from "~/domains/auth/index.js";
import type { ApiKeyResult } from "~/domains/auth/types.js";
import { answerCredentialRequest } from "~/domains/gitCredential/index.js";
import { makeDefaultFs } from "~/shell/fs.js";
import { resolveHostUrl } from "~/shell/resolveHostUrl.js";

export type GitCredentialDeps = {
readonly hostUrl: string;
readonly readRequest: () => Promise<string>;
readonly resolveCredential: () => Promise<ApiKeyResult | undefined>;
readonly writeError: (message: string) => void;
readonly writeOutput: (output: string) => void;
};

export function defaultGitCredentialDeps(): GitCredentialDeps {
return {
hostUrl: resolveHostUrl(process.env),
readRequest: () => text(process.stdin),
resolveCredential: () => resolveApiKey(getConfigDir(), makeDefaultFs()),
writeError: (message) => process.stderr.write(message),
writeOutput: (output) => process.stdout.write(output),
};
}

export async function handleGitCredential({
deps,
operation,
}: {
deps: GitCredentialDeps;
operation: string;
}): Promise<void> {
const answer = await answerCredentialRequest({
hostUrl: deps.hostUrl,
operation,
readRequest: deps.readRequest,
resolveCredential: deps.resolveCredential,
});
if (answer.status === "answered") deps.writeOutput(answer.output);
if (answer.status === "signed-out") {
deps.writeError(`${authMessages.gitHelper.signedOut}\n`);
}
if (answer.status === "team-api-key") {
deps.writeError(`${authMessages.gitHelper.teamApiKey}\n`);
}
}
80 changes: 80 additions & 0 deletions src/commands/auth/gitHelperSetup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { authMessages } from "~/core/messages/index.js";
import {
defaultCliInvocation,
type GitConfigDeps,
type GitHelperRemoval,
type GitHelperSetup,
removeGitCredentialHelper,
resolveHelperCommand,
setUpGitCredentialHelper,
} from "~/domains/gitCredential/index.js";
import type { CommandContext } from "~/shell/commandContext.js";
import { defaultSpawn } from "~/shell/spawn.js";
import type { UI } from "~/shell/ui/index.js";

const defaultGitConfigDeps: GitConfigDeps = {
platform: process.platform,
spawn: defaultSpawn,
};

function reportSetup({ result, ui }: { result: GitHelperSetup; ui: UI }): void {
switch (result.status) {
case "added":
ui.info(authMessages.gitHelper.added(result.file));
ui.note(result.lines);
return;
case "unchanged":
ui.info(authMessages.gitHelper.unchanged);
return;
case "git-missing":
ui.info(authMessages.gitHelper.gitMissing);
return;
case "temporary-install":
ui.warn(authMessages.gitHelper.temporaryInstall);
return;
case "failed":
ui.warn(authMessages.gitHelper.failed(result.detail));
return;
}
}

function reportRemoval({
result,
ui,
}: {
result: GitHelperRemoval;
ui: UI;
}): void {
if (result.status === "removed") {
ui.info(authMessages.gitHelper.removed(result.file));
ui.note(result.lines);
}
if (result.status === "failed") {
ui.warn(authMessages.gitHelper.removalFailed(result.detail));
}
}

export async function configureGitCredentialHelper(
ctx: CommandContext,
): Promise<void> {
reportSetup({
result: await setUpGitCredentialHelper({
deps: defaultGitConfigDeps,
helperCommand: resolveHelperCommand(defaultCliInvocation()),
hostUrl: ctx.apiBaseUrl,
}),
ui: ctx.ui,
});
}

export async function unconfigureGitCredentialHelper(
ctx: CommandContext,
): Promise<void> {
reportRemoval({
result: await removeGitCredentialHelper({
deps: defaultGitConfigDeps,
hostUrl: ctx.apiBaseUrl,
}),
ui: ctx.ui,
});
}
16 changes: 16 additions & 0 deletions src/commands/auth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@ import type { Command } from "commander";
import { declareCommandKind } from "~/commands/commandKind.js";
import { withContext } from "~/commands/context.js";
import type { SignalRegistry } from "~/shell/signals/createSignalRegistry.js";
import {
defaultGitCredentialDeps,
handleGitCredential,
} from "./gitCredential.js";
import { handleLogin } from "./login.js";
import { handleLogout } from "./logout.js";
import { handleSwitchWorkspace } from "./switchWorkspace.js";
Expand Down Expand Up @@ -31,4 +35,16 @@ export function registerAuthCommand(
declareCommandKind(auth.command("whoami"), "read")
.description("Show authentication status")
.action(withContext(signals, handleWhoami));

declareCommandKind(
auth.command("git-credential", { hidden: true }).argument("<operation>"),
"read",
)
.description("Answer a Git credential request for the QA Wolf host")
.action(async (operation: string) => {
await handleGitCredential({
deps: defaultGitCredentialDeps(),
operation,
});
});
}
84 changes: 84 additions & 0 deletions src/commands/auth/login.gitHelper.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { describe, expect, it, mock } from "bun:test";

import type { ApiKeyResult } from "~/domains/auth/types.js";
import type { CommandContext } from "~/shell/commandContext.js";
import { makeCtx } from "~/shell/commandContext.testUtils.js";
import type { UI } from "~/shell/ui/index.js";

import { handleLogin } from "./login.js";

const browserSession: ApiKeyResult = {
key: "access_new",
source: "browser",
workspaceId: undefined,
};

function makeHumanCtx(ui: Partial<UI>): CommandContext {
const ctx = makeCtx("human");
return { ...ctx, ui: { ...ctx.ui, ...ui } };
}

function chooseBrowser(): Partial<UI> {
return {
select: mock(async () => ({ ok: true as const, value: "browser" })),
};
}

describe("handleLogin and Git", () => {
it("sets up Git after a browser sign-in stores a credential", async () => {
const configureGitCredentialHelper = mock(async () => undefined);
const resolveApiKey = mock(
async (): Promise<ApiKeyResult | undefined> => browserSession,
).mockResolvedValueOnce(undefined);

await handleLogin(makeHumanCtx(chooseBrowser()), {
configureGitCredentialHelper,
loginWithDevice: mock(async () => undefined),
resolveApiKey,
});

expect(configureGitCredentialHelper).toHaveBeenCalledTimes(1);
});

it("leaves Git alone when sign-in ends without a credential", async () => {
const configureGitCredentialHelper = mock(async () => undefined);

await handleLogin(makeHumanCtx(chooseBrowser()), {
configureGitCredentialHelper,
loginWithDevice: mock(async () => undefined),
resolveApiKey: mock(async () => undefined),
});

expect(configureGitCredentialHelper).not.toHaveBeenCalled();
});

it("leaves Git alone when sign-in fails", async () => {
const configureGitCredentialHelper = mock(async () => undefined);

await handleLogin(makeHumanCtx(chooseBrowser()), {
configureGitCredentialHelper,
loginWithDevice: mock(async () => ({ error: "device flow expired" })),
resolveApiKey: mock(
async (): Promise<ApiKeyResult | undefined> => browserSession,
).mockResolvedValueOnce(undefined),
});

expect(configureGitCredentialHelper).not.toHaveBeenCalled();
});

it("sets up Git when the user keeps the existing sign-in", async () => {
const configureGitCredentialHelper = mock(async () => undefined);

await handleLogin(
makeHumanCtx({
confirm: mock(async () => ({ ok: true as const, value: false })),
}),
{
configureGitCredentialHelper,
resolveApiKey: mock(async () => browserSession),
},
);

expect(configureGitCredentialHelper).toHaveBeenCalledTimes(1);
});
});
1 change: 1 addition & 0 deletions src/commands/auth/login.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ function makeDeps(
resolveApiKey: overrides.resolveApiKey ?? (async () => undefined),
loginWithApiKey: mock(async () => undefined),
loginWithDevice: mock(async () => undefined),
configureGitCredentialHelper: mock(async () => undefined),
};
}

Expand Down
Loading
Loading