/** * @file Tests for Settings → Git → Your git identity: a user with no key * makes one, a key shows only its public half and copies it, replacing asks * first, and a member (S3) adds an HTTPS login of their own while the host's * view leaves HTTPS to the shared Git credentials table, and an SSH user per * host is saved — AWS CodeCommit's key ID inside its own RSA section, any * other host under "Other SSH hosts" — each kind of host in its own tab, so * the sections never mix; "Add another key" makes a named key as one more * card in its own tab, a second AWS account is saved from that card under * the key's name with that key, and a second GitHub key is tied to its host; * commit signing PER WORKSPACE: an imported key signs nothing by itself, one * workspace's picker signs that workspace only, a signing key is deleted from * the card after a confirm, and the old account-wide setting and a setup the * user made themselves each have a way out. * @author Nguyễn Ngọc Trí Vĩ */ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { GitIdentitySettings } from "../GitIdentitySettings"; import { api, type GitIdentity } from "../../../lib/api"; import { confirmDialog } from "../../../lib/confirm"; import { copyText } from "../../../lib/clipboard"; vi.mock("../../../lib/api", () => ({ api: { gitIdentity: { get: vi.fn(), generateKey: vi.fn(), removeKey: vi.fn(), removeKeyById: vi.fn(), setTerminalSsh: vi.fn(), setSshUser: vi.fn(), removeSshUser: vi.fn(), saveHttps: vi.fn(), saveAuthor: vi.fn(), removeHttps: vi.fn(), importKey: vi.fn(), setSigning: vi.fn(), setLaneSigning: vi.fn(), }, lanes: { list: vi.fn(async () => ({ lanes: [ { id: 1, title: "shop-api", cwd: "/w/shop-api" }, { id: 2, title: "", cwd: "/w/docs" }, ], counts: {}, })), }, }, })); vi.mock("../../../lib/toast", () => ({ toast: { success: vi.fn(), error: vi.fn() } })); vi.mock("../../../lib/confirm", () => ({ confirmDialog: vi.fn(() => Promise.resolve(true)) })); vi.mock("../../../lib/clipboard", () => ({ copyText: vi.fn(() => Promise.resolve(true)) })); const OFF = { state: "off" as const, key: null, path: null }; const KEY = { publicKey: "ssh-ed25519 AAAAC3Nz an@agenthub", fingerprint: "SHA256:abc" }; const view = (over: Partial = {}): GitIdentity => ({ account: "member", writable: true, ssh: null, https: [], author: { name: "An", email: "an@x.vn" }, ...over, }); /** Opens one tab of the SSH keys. */ const tab = async (name: string | RegExp) => userEvent.click(await screen.findByRole("tab", { name })); beforeEach(() => { vi.clearAllMocks(); }); describe("GitIdentitySettings", () => { it("makes a key when there is none, without asking", async () => { vi.mocked(api.gitIdentity.get).mockResolvedValue(view()); vi.mocked(api.gitIdentity.generateKey).mockResolvedValue(view({ ssh: KEY })); render(); const gh = await screen.findByRole("region", { name: /GitHub/ }); await userEvent.click(within(gh).getByRole("button", { name: "Generate key" })); expect(confirmDialog).not.toHaveBeenCalled(); expect(api.gitIdentity.generateKey).toHaveBeenCalledWith(false, "ed25519"); expect(await screen.findByLabelText(/^Public key/)).toHaveValue(KEY.publicKey); }); it("copies the public key and asks before replacing it", async () => { vi.mocked(api.gitIdentity.get).mockResolvedValue(view({ ssh: KEY })); vi.mocked(api.gitIdentity.generateKey).mockResolvedValue(view({ ssh: KEY })); render(); await userEvent.click(await screen.findByRole("button", { name: "Copy public key" })); expect(copyText).toHaveBeenCalledWith(KEY.publicKey); await userEvent.click(screen.getByRole("button", { name: "Replace key" })); expect(confirmDialog).toHaveBeenCalled(); await waitFor(() => expect(api.gitIdentity.generateKey).toHaveBeenCalledWith(true, "ed25519")); }); it("lets a member save an https login of their own", async () => { vi.mocked(api.gitIdentity.get).mockResolvedValue(view()); vi.mocked(api.gitIdentity.saveHttps).mockResolvedValue( view({ https: [{ protocol: "https", host: "github.com", username: "an", hasToken: true }] }) ); render(); await userEvent.click(await screen.findByRole("button", { name: "Add login" })); await userEvent.type(screen.getByLabelText("Server URL"), "https://github.com/o/r"); await userEvent.type(screen.getByLabelText("Username"), "an"); await userEvent.type(screen.getByLabelText("Password or access token"), "tok"); // The second Save: the first belongs to the commit-author form. await userEvent.click(screen.getAllByRole("button", { name: "Save" })[1]!); expect(api.gitIdentity.saveHttps).toHaveBeenCalledWith({ host: "github.com", username: "an", password: "tok", }); expect(await screen.findByText("github.com")).toBeInTheDocument(); }); it("makes the RSA key from the AWS CodeCommit section", async () => { vi.mocked(api.gitIdentity.get).mockResolvedValue(view()); vi.mocked(api.gitIdentity.generateKey).mockResolvedValue( view({ ssh: { ...KEY, type: "rsa", publicKey: "ssh-rsa AAAAB3 an@agenthub" } }) ); render(); await tab("AWS CodeCommit"); const aws = await screen.findByRole("region", { name: "AWS CodeCommit" }); await userEvent.click(within(aws).getByRole("button", { name: "Generate key" })); expect(api.gitIdentity.generateKey).toHaveBeenCalledWith(false, "rsa"); expect(await screen.findByLabelText("Public key RSA")).toBeInTheDocument(); }); it("keeps each key in its own section and deletes each on its own", async () => { const RSA = { type: "rsa" as const, publicKey: "ssh-rsa AAAAB3 an@agenthub", fingerprint: "SHA256:r", }; const ED = { ...KEY, type: "ed25519" as const }; vi.mocked(api.gitIdentity.get).mockResolvedValue( view({ ssh: ED, sshKeys: [ED, RSA], sshTerminal: "on" }) ); vi.mocked(api.gitIdentity.removeKey).mockResolvedValue(view({ ssh: ED, sshKeys: [ED] })); render(); const gh = await screen.findByRole("region", { name: /GitHub/ }); expect(within(gh).getByLabelText("Public key ED25519")).toHaveValue(ED.publicKey); // One tab at a time: the other host's section is not on screen. expect(screen.queryByRole("region", { name: "AWS CodeCommit" })).toBeNull(); expect(within(gh).queryByText(/IAM/)).toBeNull(); await tab("AWS CodeCommit"); const aws = screen.getByRole("region", { name: "AWS CodeCommit" }); expect(screen.queryByRole("region", { name: /GitHub/ })).toBeNull(); expect(within(aws).getByLabelText("Public key RSA")).toHaveValue(RSA.publicKey); expect(within(aws).queryByText(/GitHub/)).toBeNull(); expect(screen.getByRole("switch", { name: /terminal/ })).toBeChecked(); await userEvent.click(within(aws).getByRole("button", { name: "Delete" })); await waitFor(() => expect(api.gitIdentity.removeKey).toHaveBeenCalledWith("rsa")); }); it("saves the CodeCommit SSH key ID for every region — no region to pick", async () => { const RSA = { type: "rsa" as const, publicKey: "ssh-rsa AAAAB3 x", fingerprint: "SHA256:r" }; const REGIONS = ["us-east-1", "eu-west-1", "ap-northeast-1"]; const host = (r: string) => `git-codecommit.${r}.amazonaws.com`; vi.mocked(api.gitIdentity.get).mockResolvedValue( view({ sshKeys: [RSA], sshUsers: [], codecommitRegions: REGIONS }) ); vi.mocked(api.gitIdentity.setSshUser).mockResolvedValue( view({ sshKeys: [RSA], sshUsers: REGIONS.map((r) => ({ host: host(r), user: "APKA1" })), codecommitRegions: REGIONS, }) ); vi.mocked(api.gitIdentity.removeSshUser).mockResolvedValue( view({ sshKeys: [RSA], sshUsers: [], codecommitRegions: REGIONS }) ); render(); const aws = await screen.findByRole("region", { name: "AWS CodeCommit" }); expect(within(aws).queryByLabelText("Region")).toBeNull(); await userEvent.type(within(aws).getByLabelText("SSH key ID"), " APKA1 "); await userEvent.click(within(aws).getByRole("button", { name: "Save" })); expect(api.gitIdentity.setSshUser).toHaveBeenCalledWith({ host: "git-codecommit.us-east-1.amazonaws.com", user: "APKA1", allRegions: true, }); // One row for the key ID, not one per region. expect(await within(aws).findByText("Every region · APKA1 · Any key")).toBeInTheDocument(); // A CodeCommit host never shows up again under "other hosts". expect(screen.queryByText(`APKA1@${host("eu-west-1")}`)).toBeNull(); await userEvent.click(within(aws).getByRole("button", { name: "Delete APKA1" })); expect(api.gitIdentity.removeSshUser).toHaveBeenCalledWith(host("us-east-1"), { allRegions: true, user: "APKA1", }); }); it("offers an older one-region CodeCommit login for every region", async () => { const RSA = { type: "rsa" as const, publicKey: "ssh-rsa AAAAB3 x", fingerprint: "SHA256:r" }; const H = "git-codecommit.ap-northeast-1.amazonaws.com"; vi.mocked(api.gitIdentity.get).mockResolvedValue( view({ sshKeys: [RSA], sshUsers: [{ host: H, user: "APKA9" }], codecommitRegions: ["us-east-1", "ap-northeast-1"], }) ); vi.mocked(api.gitIdentity.setSshUser).mockResolvedValue(view({ sshKeys: [RSA] })); render(); const aws = await screen.findByRole("region", { name: "AWS CodeCommit" }); expect(await within(aws).findByText("ap-northeast-1 · APKA9 · Any key")).toBeInTheDocument(); await userEvent.click(within(aws).getByRole("button", { name: "Use in every region" })); expect(api.gitIdentity.setSshUser).toHaveBeenCalledWith({ host: H, user: "APKA9", allRegions: true, }); }); it("saves a user for any other SSH host under the advanced section", async () => { vi.mocked(api.gitIdentity.get).mockResolvedValue(view({ ssh: KEY, sshUsers: [] })); vi.mocked(api.gitIdentity.setSshUser).mockResolvedValue( view({ ssh: KEY, sshUsers: [{ host: "git.example.com", user: "deploy" }] }) ); render(); await tab("Other hosts"); await userEvent.type(screen.getByLabelText("Host"), "ssh://git.example.com/x"); await userEvent.type(screen.getByLabelText("User name"), "deploy"); const form = screen.getByLabelText("Host").closest("form")!; await userEvent.click(within(form).getByRole("button", { name: "Save" })); expect(api.gitIdentity.setSshUser).toHaveBeenCalledWith({ host: "git.example.com", user: "deploy", }); expect(await screen.findByText("deploy@git.example.com · Any key")).toBeInTheDocument(); }); it("warns while the commit author is unset and saves it", async () => { vi.mocked(api.gitIdentity.get).mockResolvedValue(view({ author: { name: null, email: null } })); vi.mocked(api.gitIdentity.saveAuthor).mockResolvedValue(view()); render(); expect( await screen.findByText(/commits from the dashboard and your agents fail/) ).toBeInTheDocument(); await userEvent.type(screen.getByLabelText("Name"), "An Nguyen"); await userEvent.type(screen.getByLabelText("Email"), "an@x.vn"); await userEvent.click(screen.getAllByRole("button", { name: "Save" })[0]!); expect(api.gitIdentity.saveAuthor).toHaveBeenCalledWith({ name: "An Nguyen", email: "an@x.vn", }); }); it("shows the host's key read-only and no https list", async () => { vi.mocked(api.gitIdentity.get).mockResolvedValue( view({ account: "host", writable: false, ssh: KEY }) ); render(); expect(await screen.findByLabelText(/^Public key/)).toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Replace key" })).toBeNull(); expect(screen.queryByRole("button", { name: "Add login" })).toBeNull(); }); it("makes a named key and saves a second AWS account under its name with that key", async () => { const RSA = { id: "id_rsa", type: "rsa" as const, name: null, publicKey: "ssh-rsa A x", fingerprint: "SHA256:r", }; const PROD = { id: "id_rsa_aws-2", type: "rsa" as const, name: "aws-2", publicKey: "ssh-rsa B prod", fingerprint: "SHA256:p", }; const H = "git-codecommit.ap-southeast-1.amazonaws.com"; vi.mocked(api.gitIdentity.get).mockResolvedValue(view({ sshKeys: [RSA], sshUsers: [] })); vi.mocked(api.gitIdentity.generateKey).mockResolvedValue( view({ sshKeys: [RSA, PROD], sshUsers: [] }) ); vi.mocked(api.gitIdentity.setSshUser).mockResolvedValue( view({ sshKeys: [RSA, PROD], sshUsers: [{ host: H, user: "APKA2", alias: "aws-2", key: "id_rsa_aws-2" }], }) ); vi.mocked(api.gitIdentity.removeKeyById).mockResolvedValue( view({ sshKeys: [RSA], sshUsers: [] }) ); render(); // Only an RSA key: the AWS tab opens first; the new key is made inside it. expect(await screen.findByRole("region", { name: "AWS CodeCommit" })).toBeInTheDocument(); expect(screen.queryByRole("tab", { name: /More keys/ })).toBeNull(); // One click makes the key — nothing to type before it. await userEvent.click(screen.getByRole("button", { name: "Add another key" })); expect(api.gitIdentity.generateKey).toHaveBeenCalledWith(false, "rsa", "aws-2"); const prod = await screen.findByRole("region", { name: "aws-2" }); expect(within(prod).getByLabelText("Public key aws-2")).toHaveValue("ssh-rsa B prod"); // Another key can be added below it. expect(screen.getByRole("button", { name: "Add another key" })).toBeInTheDocument(); // Its own step 3: only the key ID; the login goes by the key's name. await userEvent.type(within(prod).getByLabelText("SSH key ID"), "APKA2"); await userEvent.click(within(prod).getByRole("button", { name: "Save" })); expect(api.gitIdentity.setSshUser).toHaveBeenCalledWith({ host: "git-codecommit.us-east-1.amazonaws.com", user: "APKA2", alias: "aws-2", key: "id_rsa_aws-2", }); expect(await within(prod).findByText("aws-2 · APKA2 · aws-2")).toBeInTheDocument(); // The default key's card does not list the named key's login. const def = screen.getByRole("region", { name: "AWS CodeCommit" }); expect(within(def).queryByText(/APKA2/)).toBeNull(); await userEvent.click(within(prod).getByRole("button", { name: "Delete key aws-2" })); await waitFor(() => expect(api.gitIdentity.removeKeyById).toHaveBeenCalledWith("id_rsa_aws-2")); }); it("adds a second GitHub key in its tab and ties it to its host", async () => { const ED = { ...KEY, id: "id_ed25519", type: "ed25519" as const, name: null }; const WORK = { id: "id_ed25519_git-2", type: "ed25519" as const, name: "git-2", publicKey: "ssh-ed25519 W work", fingerprint: "SHA256:w", }; vi.mocked(api.gitIdentity.get).mockResolvedValue(view({ sshKeys: [ED], sshUsers: [] })); vi.mocked(api.gitIdentity.generateKey).mockResolvedValue( view({ sshKeys: [ED, WORK], sshUsers: [] }) ); vi.mocked(api.gitIdentity.setSshUser).mockResolvedValue( view({ sshKeys: [ED, WORK], sshUsers: [{ host: "github.com", user: "git", alias: "git-2", key: "id_ed25519_git-2" }], }) ); render(); await userEvent.click(await screen.findByRole("button", { name: "Add another key" })); expect(api.gitIdentity.generateKey).toHaveBeenCalledWith(false, "ed25519", "git-2"); const work = await screen.findByRole("region", { name: "git-2" }); expect(within(work).getByLabelText("Public key git-2")).toHaveValue("ssh-ed25519 W work"); await userEvent.click(within(work).getByRole("button", { name: "Save" })); expect(api.gitIdentity.setSshUser).toHaveBeenCalledWith({ host: "github.com", user: "git", alias: "git-2", key: "id_ed25519_git-2", }); expect(await within(work).findByText("ssh://git-2/ → git@github.com")).toBeInTheDocument(); }); it("imports a key without signing anything by itself", async () => { const signingKey = { ...KEY, id: "id_ed25519_signing", type: "ed25519" as const, name: "signing", }; vi.mocked(api.gitIdentity.get).mockResolvedValue( view({ sshKeys: [], signing: { state: "off", key: null, path: null }, laneSigning: [] }) ); vi.mocked(api.gitIdentity.importKey).mockResolvedValue({ ...view({ sshKeys: [signingKey], signing: OFF, laneSigning: [] }), imported: signingKey, signError: null, }); render(); expect(await screen.findByText(/Import a key you have, or make one/)).toBeInTheDocument(); await userEvent.click(await screen.findByRole("button", { name: "Import a key you have" })); const form = screen.getByRole("form", { name: "Import an existing private key" }); await userEvent.type( within(form).getByLabelText(/paste the private key/), "-----BEGIN OPENSSH PRIVATE KEY-----x" ); await userEvent.click(within(form).getByRole("button", { name: "Import" })); expect(api.gitIdentity.importKey).toHaveBeenCalledWith({ privateKey: "-----BEGIN OPENSSH PRIVATE KEY-----x", }); // Now there is a key: each workspace gets its own picker, all "Don't sign". expect( await screen.findByLabelText("Signing key for shop-api", {}, { timeout: 5000 }) ).toHaveValue(""); expect(screen.getByLabelText("Signing key for /w/docs")).toHaveValue(""); expect(api.gitIdentity.setSigning).not.toHaveBeenCalled(); }); it("signs one workspace only, and stops it", async () => { const k = { ...KEY, id: "id_ed25519_signing", type: "ed25519" as const, name: "signing" }; vi.mocked(api.gitIdentity.get).mockResolvedValue( view({ sshKeys: [k], signing: { state: "off", key: null, path: null }, laneSigning: [] }) ); vi.mocked(api.gitIdentity.setLaneSigning) .mockResolvedValueOnce( view({ sshKeys: [k], signing: OFF, laneSigning: [{ laneId: 1, key: k.id }] }) ) .mockResolvedValueOnce(view({ sshKeys: [k], signing: OFF, laneSigning: [] })); render(); const pick = await screen.findByLabelText("Signing key for shop-api", {}, { timeout: 5000 }); await userEvent.selectOptions(pick, k.id); expect(api.gitIdentity.setLaneSigning).toHaveBeenCalledWith(1, k.id); await waitFor(() => expect(screen.getByLabelText("Signing key for shop-api")).toHaveValue(k.id) ); expect(screen.getByLabelText("Signing key for /w/docs")).toHaveValue(""); expect( within(screen.getByTestId("git-signing-keys")).getByText(/signs 1 workspace/) ).toBeTruthy(); await userEvent.selectOptions(screen.getByLabelText("Signing key for shop-api"), ""); expect(api.gitIdentity.setLaneSigning).toHaveBeenLastCalledWith(1, null); expect(api.gitIdentity.setSigning).not.toHaveBeenCalled(); }); it("lists a removed workspace that still signs, so it can be turned off", async () => { const k = { ...KEY, id: "id_ed25519_signing", type: "ed25519" as const, name: "signing" }; vi.mocked(api.gitIdentity.get).mockResolvedValue( view({ sshKeys: [k], signing: OFF, laneSigning: [{ laneId: 9, key: k.id }] }) ); vi.mocked(api.gitIdentity.setLaneSigning).mockResolvedValue( view({ sshKeys: [k], signing: OFF, laneSigning: [] }) ); render(); const pick = await screen.findByLabelText( "Signing key for Workspace #9 (removed)", {}, { timeout: 5000 } ); expect(pick).toHaveValue(k.id); await userEvent.selectOptions(pick, ""); expect(api.gitIdentity.setLaneSigning).toHaveBeenCalledWith(9, null); await waitFor(() => expect(screen.queryByLabelText("Signing key for Workspace #9 (removed)")).toBeNull() ); }); it("deletes a signing key from the signing card, after asking", async () => { const k = { ...KEY, id: "id_ed25519_signing", type: "ed25519" as const, name: "signing" }; vi.mocked(api.gitIdentity.get).mockResolvedValue( view({ sshKeys: [k], signing: { state: "off", key: null, path: null }, laneSigning: [{ laneId: 1, key: k.id }], }) ); vi.mocked(api.gitIdentity.removeKeyById).mockResolvedValue( view({ sshKeys: [], signing: OFF, laneSigning: [] }) ); render(); const keys = await screen.findByTestId("git-signing-keys", {}, { timeout: 5000 }); await userEvent.click(within(keys).getByRole("button", { name: "Delete key signing" })); expect(confirmDialog).toHaveBeenCalledWith( expect.objectContaining({ title: expect.stringMatching(/every workspace signing with it/) }) ); expect(api.gitIdentity.removeKeyById).toHaveBeenCalledWith(k.id); await waitFor(() => expect(screen.queryByTestId("git-signing-keys")).toBeNull()); }); it("turns off the old account-wide signing and removes a setup the user made", async () => { const k = { ...KEY, id: "id_ed25519", type: "ed25519" as const, name: null }; vi.mocked(api.gitIdentity.get).mockResolvedValue( view({ sshKeys: [k], signing: { state: "on", key: "id_ed25519", path: null } }) ); vi.mocked(api.gitIdentity.setSigning).mockResolvedValue( view({ sshKeys: [k], signing: { state: "foreign", key: null, path: "~/.ssh/mine" } }) ); render(); const legacy = await screen.findByTestId("git-signing-legacy"); await userEvent.click(within(legacy).getByRole("button", { name: "Sign per workspace only" })); expect(api.gitIdentity.setSigning).toHaveBeenCalledWith(null); const foreign = await screen.findByTestId("git-signing-foreign"); expect(foreign).toHaveTextContent("~/.ssh/mine"); await userEvent.click(within(foreign).getByRole("button", { name: "Remove" })); expect(confirmDialog).toHaveBeenCalledWith( expect.objectContaining({ title: expect.stringMatching(/key file itself is not deleted/) }) ); expect(api.gitIdentity.setSigning).toHaveBeenLastCalledWith(null, false, true); }); });