Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
64 changes: 64 additions & 0 deletions web/e2e/funnel.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -406,3 +406,67 @@ test("guide routes render their page client-side", async ({ page }) => {
page.getByRole("heading", { name: "Share .env files without the screenshot dance." }),
).toBeVisible();
});

test("a revealed secret can be hidden and revealed again", async ({ page }) => {
await loginAndUnlock(page);
const project = page
.getByRole("button", { name: new RegExp(fixture.project_name) })
.first();
await project.click();
const envButton = page
.getByRole("heading", { name: "Environments" })
.locator("xpath=following-sibling::ul[1]")
.getByRole("button")
.first();
await envButton.click();
const secretButton = page.getByRole("button", { name: fixture.secret_name });
await secretButton.click();
await expect(page.locator("textarea.secret-value")).toHaveValue(fixture.secret_value);

await page.getByRole("button", { name: "Hide secret" }).press("Enter");
await expect(page.locator("textarea.secret-value")).toHaveCount(0);
await expect(
page.getByLabel("Share link (burns after one view):"),
).toHaveCount(0);
await expect(secretButton).not.toHaveAttribute("aria-current");
await expect(secretButton).toBeFocused();

await secretButton.press("Enter");
await expect(page.locator("textarea.secret-value")).toHaveValue(fixture.secret_value);
});

test("a pending share cannot restore a hidden secret", async ({ page }) => {
let releaseShare!: () => void;
const shareReleased = new Promise<void>((resolve) => { releaseShare = resolve; });
await page.route("**/shares", async (route) => {
if (route.request().method() !== "POST") {
await route.continue();
return;
}
await shareReleased;
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ token: "delayed-e2e-token" }),
});
});

await loginAndUnlock(page);
await page
.getByRole("button", { name: new RegExp(fixture.project_name) })
.first()
.click();
await page
.getByRole("heading", { name: "Environments" })
.locator("xpath=following-sibling::ul[1]")
.getByRole("button")
.first()
.click();
await page.getByRole("button", { name: fixture.secret_name }).click();
await page.getByRole("button", { name: "Create one-time share link" }).click();
await page.getByRole("button", { name: "Hide secret" }).click();
releaseShare();

await expect(page.locator("textarea.secret-value")).toHaveCount(0);
await expect(page.locator("textarea.share-link")).toHaveCount(0);
});
36 changes: 33 additions & 3 deletions web/src/VaultView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ interface OpenEnv {
secrets: NamedSecret[];
}
interface Revealed {
id: string;
name: string;
value: string;
link: string | null;
Expand Down Expand Up @@ -108,6 +109,8 @@ export function VaultView({
// Selection loads can resolve out of order; only the latest generation may update the view.
const projectLoad = useRef(0);
const envLoad = useRef(0);
const revealGeneration = useRef(0);
const secretButtons = useRef(new Map<string, HTMLButtonElement>());

/// The name-decryption key for a project: its org key when we hold one, else the master key.
function nameKeyFor(orgId: string | null): Uint8Array {
Expand Down Expand Up @@ -406,7 +409,8 @@ export function VaultView({
setError(null);
try {
const value = decryptSecretValue(openEnv.vaultKey, openEnv.envId, ns.entry);
setRevealed({ name: ns.name, value, link: null });
++revealGeneration.current;
setRevealed({ id: ns.entry.id, name: ns.name, value, link: null });
} catch (e) {
setError(message(e));
}
Expand All @@ -433,13 +437,19 @@ export function VaultView({
}

async function share(current: Revealed) {
const generation = revealGeneration.current;
try {
const { encBlob, fragmentKey } = sealForShare(current.value);
const token = await createShare(encBlob, 1);
if (generation !== revealGeneration.current) {
return;
}
const link = `${window.location.origin}/s/${token}#${bytesToUrlSafeB64(fragmentKey)}`;
setRevealed({ ...current, link });
} catch (e) {
setError(message(e));
if (generation === revealGeneration.current) {
setError(message(e));
}
}
}

Expand All @@ -455,6 +465,16 @@ export function VaultView({
? "No secret names match this search."
: `${filteredSecrets.length} secret ${filteredSecrets.length === 1 ? "name matches" : "names match"} this search.`;

function hideRevealed() {
if (revealed === null) {
return;
}
const id = revealed.id;
++revealGeneration.current;
setRevealed(null);
secretButtons.current.get(id)?.focus();
}

return (
<Shell onLogout={onLogout}>
<h1>Your vault</h1>
Expand Down Expand Up @@ -551,8 +571,15 @@ export function VaultView({
{filteredSecrets.map((s) => (
<li key={s.entry.id}>
<button
ref={(node) => {
if (node === null) {
secretButtons.current.delete(s.entry.id);
} else {
secretButtons.current.set(s.entry.id, node);
}
}}
onClick={() => reveal(s)}
aria-current={revealed?.name === s.name ? "true" : undefined}
aria-current={revealed?.id === s.entry.id ? "true" : undefined}
>
{s.name}
</button>
Expand Down Expand Up @@ -626,6 +653,9 @@ export function VaultView({
{copyBusy ? "Copying…" : "Copy secret"}
</button>
{copyFeedback !== null && <p role="status">{copyFeedback}</p>}
<button className="ghost" onClick={hideRevealed}>
Hide secret
</button>
{revealed.link === null ? (
<button className="ghost" onClick={() => void share(revealed)}>
Create one-time share link
Expand Down
91 changes: 91 additions & 0 deletions web/src/__tests__/VaultView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1029,3 +1029,94 @@ describe("VaultView secret copying", () => {
expect(screen.getByRole("button", { name: "Copy secret" })).toBeEnabled();
});
});

describe("VaultView secret hiding", () => {
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(api.fetchOrgs).mockResolvedValue([]);
vi.mocked(api.fetchProjects).mockResolvedValue([project("project-a")]);
vi.mocked(api.fetchMembers).mockResolvedValue([]);
vi.mocked(api.fetchMyGrant).mockResolvedValue(new Uint8Array([7]));
vi.mocked(api.fetchSecrets).mockResolvedValue([]);
vi.mocked(vault.decryptProjectName).mockImplementation((_key, id) => id);
vi.mocked(vault.decryptEnvName).mockImplementation((_key, id) => id);
vi.mocked(vault.decryptSecretName).mockImplementation((_key, _envId, entry) => entry.id);
vi.mocked(vault.openEnvGrant).mockReturnValue(new Uint8Array([8]));
});
it("hides a revealed secret, returns focus, and allows revealing it again", async () => {
vi.mocked(api.fetchEnvironments).mockResolvedValue([environment("env-a")]);
vi.mocked(api.fetchSecrets).mockResolvedValue([secret("secret-a")]);
vi.mocked(vault.decryptSecretName).mockReturnValue("secret-a");
vi.mocked(vault.decryptSecretValue).mockReturnValue("secret-value");

renderVault();
fireEvent.click(await screen.findByRole("button", { name: /project-a/ }));
fireEvent.click(await screen.findByRole("button", { name: "env-a" }));
const secretButton = await screen.findByRole("button", { name: "secret-a" });

fireEvent.click(secretButton);
expect(screen.getByDisplayValue("secret-value")).toBeInTheDocument();
expect(secretButton).toHaveAttribute("aria-current", "true");

fireEvent.click(screen.getByRole("button", { name: "Hide secret" }));
expect(screen.queryByDisplayValue("secret-value")).not.toBeInTheDocument();
expect(secretButton).not.toHaveAttribute("aria-current");
expect(secretButton).toHaveFocus();

fireEvent.click(secretButton);
expect(screen.getByDisplayValue("secret-value")).toBeInTheDocument();
});

it("keeps a newly revealed secret selected after an older share finishes", async () => {
vi.mocked(api.fetchEnvironments).mockResolvedValue([environment("env-a")]);
vi.mocked(api.fetchSecrets).mockResolvedValue([secret("secret-a"), secret("secret-b")]);
vi.mocked(vault.decryptSecretName).mockImplementation((_key, _env, entry) => entry.id);
vi.mocked(vault.decryptSecretValue).mockReturnValue("secret-value");
vi.mocked(vault.sealForShare).mockReturnValue({
encBlob: new Uint8Array([1]), fragmentKey: new Uint8Array([2]),
});
const shareRequest = deferred<string>();
vi.mocked(api.createShare).mockReturnValue(shareRequest.promise);

renderVault();
fireEvent.click(await screen.findByRole("button", { name: /project-a/ }));
fireEvent.click(await screen.findByRole("button", { name: "env-a" }));
fireEvent.click(await screen.findByRole("button", { name: "secret-a" }));
fireEvent.click(screen.getByRole("button", { name: "Create one-time share link" }));
await waitFor(() => expect(api.createShare).toHaveBeenCalledOnce());

const secretB = screen.getByRole("button", { name: "secret-b" });
fireEvent.click(secretB);
await act(async () => shareRequest.resolve("late-token"));

expect(secretB).toHaveAttribute("aria-current", "true");
expect(screen.queryByDisplayValue(/late-token/)).not.toBeInTheDocument();
expect(screen.getByDisplayValue("secret-value")).toBeInTheDocument();
});

it("does not restore a hidden secret when a pending share finishes", async () => {
vi.mocked(api.fetchEnvironments).mockResolvedValue([environment("env-a")]);
vi.mocked(api.fetchSecrets).mockResolvedValue([secret("secret-a")]);
vi.mocked(vault.decryptSecretName).mockReturnValue("secret-a");
vi.mocked(vault.decryptSecretValue).mockReturnValue("secret-value");
vi.mocked(vault.sealForShare).mockReturnValue({
encBlob: new Uint8Array([1]),
fragmentKey: new Uint8Array([2]),
});
const shareRequest = deferred<string>();
vi.mocked(api.createShare).mockReturnValue(shareRequest.promise);

renderVault();
fireEvent.click(await screen.findByRole("button", { name: /project-a/ }));
fireEvent.click(await screen.findByRole("button", { name: "env-a" }));
fireEvent.click(await screen.findByRole("button", { name: "secret-a" }));
fireEvent.click(screen.getByRole("button", { name: "Create one-time share link" }));
await waitFor(() => expect(api.createShare).toHaveBeenCalledOnce());

fireEvent.click(screen.getByRole("button", { name: "Hide secret" }));
await act(async () => shareRequest.resolve("late-token"));

expect(screen.queryByDisplayValue("secret-value")).not.toBeInTheDocument();
expect(screen.queryByDisplayValue(/late-token/)).not.toBeInTheDocument();
});
});
Loading