Files
PaperClipAI/ui/src/pages/secrets/secret-path.test.ts
T
b94907f8af feat(ui): navigate slash-named secrets as folders (#9913)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Operators store company and user secrets under human-readable names
> - Slash-delimited names already express useful hierarchy, but the
Secrets page previously rendered them as one flat list
> - Large secret collections therefore became harder to scan, navigate,
and create within consistently named groups
> - A client-derived folder model preserves the existing server contract
while making those names navigable
> - This pull request adds folder browsing, URL-addressable paths,
global search, and create-in-folder behavior without adding folder
records
> - The benefit is a more scalable secrets workflow with no migration or
API compatibility risk

## Linked Issues or Issue Description

Slash-delimited secret names such as `dev/github/oauth/clientid`
currently appear as raw flat rows. This change treats name prefixes as
client-side navigation folders on the main Secrets tab while leaving
stored names, API contracts, validation, and the database unchanged.

- Wireframes and interaction specification:
https://pages.paperclip.ing/pap-14698-secrets-folders/
- Folder paths are derived only from secret names; there is no
server-side folder entity or data-model change.

## What Changed

- Added pure secret-path utilities for normalized segments, breadcrumbs,
nested folder listings, counts, and leaf/path rendering.
- Added a Folders/Flat view to the main Secrets tab with folder-first
sorting, breadcrumbs, empty-folder states, filters, and global search
results.
- Added URL navigation through `?path=<normalized/path>` so deep links,
reload, browser Back, and new-tab folder navigation work.
- Persisted the preferred view in `localStorage` under
`paperclip.secrets.viewMode`; an explicit `?path=` deep link takes
precedence for that visit.
- Added create-in-folder behavior with a removable prefix chip, staged
New folder paths, inline segment validation, and full-name key
derivation.
- Kept My secrets flat while rendering slash-delimited names with muted
paths and emphasized leaves.
- Added unit/render coverage for path helpers, folder navigation, and
create-in-folder behavior.

## Verification

- `pnpm exec vitest run ui/src/pages/secrets/secret-path.test.ts
ui/src/pages/Secrets.render.test.tsx` — 35 tests passed.
- `pnpm -r typecheck` — passed.
- `pnpm test:run` — server suite passed 2,707 tests and UI suite passed
3,022 tests; one unrelated CLI doctor test was environment-sensitive
because this agent inherited AWS variables.
- `env -u AWS_PROFILE -u AWS_ACCESS_KEY_ID -u AWS_SECRET_ACCESS_KEY -u
AWS_SESSION_TOKEN -u AWS_REGION -u AWS_DEFAULT_REGION pnpm exec vitest
run cli/src/__tests__/secrets.test.ts` — 8 tests passed.
- `pnpm build` — passed.
- `pnpm check:token-gates` — feature files are clean; the command
reports five existing `#9627` color literals outside this diff.

## Risks

- Low data risk: folders are derived client-side from existing names,
with no schema, migration, API, or stored-name changes.
- URL behavior changes only the main Secrets tab and uses the additive
`?path=` contract.
- View preference is browser-local and scoped to the
`paperclip.secrets.viewMode` key.
- Renaming or deleting the last secret under an open prefix
intentionally leaves the user on an empty-folder state instead of
redirecting.
- “Move to folder…” bulk prefix rename remains deferred because it is a
multi-secret mutation with separate conflict and partial-failure
semantics.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI `gpt-5.5` via Codex CLI for PR preparation, verification,
GitHub/Paperclip tool use, and codebase analysis; the managed harness
does not expose the active context-window size.
- Anthropic Claude Opus 4.8 (1M context) and Claude Fable 5 assisted
earlier implementation/design commits, as recorded in their commit
trailers; both used repository and code-editing tools.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [ ] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
— the assigned shared execution branch name is fixed for this work
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-07-20 16:11:35 -05:00

143 lines
4.9 KiB
TypeScript

import { describe, expect, it } from "vitest";
import {
buildSecretPathBreadcrumbs,
buildSecretPathListing,
getSecretPathRowName,
normalizeSecretPath,
validateSecretFolderSegment,
type SecretPathRow,
} from "./secret-path";
type TestRow = SecretPathRow & { id: string };
function companyRow(id: string, name: string): TestRow {
return { id, kind: "company", secret: { name } };
}
function userRow(id: string, name: string): TestRow {
return { id, kind: "user", definition: { name } };
}
describe("secret path normalization", () => {
it("ignores leading, duplicate, and trailing slashes without changing stored names", () => {
const rows = [
companyRow("github", "/dev//github/"),
companyRow("token", "//prod///api//token/"),
userRow("standalone", "/standalone/"),
];
expect(normalizeSecretPath("//dev///github/")).toBe("dev/github");
expect(buildSecretPathListing(rows, "/")).toEqual({
folders: [
{ name: "dev", path: "dev", secretCount: 1, folderCount: 0 },
{ name: "prod", path: "prod", secretCount: 1, folderCount: 1 },
],
secrets: [rows[2]],
});
expect(getSecretPathRowName(rows[0])).toBe("/dev//github/");
});
it("keeps no-slash names at the root", () => {
const rows = [companyRow("one", "alpha"), userRow("two", "beta")];
expect(buildSecretPathListing(rows, "")).toEqual({ folders: [], secrets: rows });
});
});
describe("buildSecretPathListing", () => {
it("shows a name as both a direct secret and a folder when it is also a prefix", () => {
const exact = companyRow("exact", "dev");
const nested = companyRow("nested", "dev/oauth/token");
expect(buildSecretPathListing([exact, nested], "")).toEqual({
folders: [{ name: "dev", path: "dev", secretCount: 1, folderCount: 1 }],
secrets: [exact],
});
expect(buildSecretPathListing([exact, nested], "dev")).toEqual({
folders: [{ name: "oauth", path: "dev/oauth", secretCount: 1, folderCount: 0 }],
secrets: [exact],
});
});
it("groups case-sensitively while sorting case-insensitively", () => {
const rows = [
companyRow("lower-child", "dev/token"),
companyRow("upper-child", "Dev/token"),
companyRow("zebra", "zebra"),
companyRow("alpha-upper", "Alpha"),
companyRow("alpha-lower", "alpha"),
];
const listing = buildSecretPathListing(rows, "");
expect(listing.folders.map((folder) => folder.name)).toEqual(["dev", "Dev"]);
expect(listing.secrets.map(getSecretPathRowName)).toEqual(["Alpha", "alpha", "zebra"]);
});
it("naturally sorts folders and secrets", () => {
const rows = [
companyRow("env10", "env10/token"),
companyRow("env2", "env2/token"),
companyRow("key10", "key10"),
companyRow("key2", "key2"),
];
const listing = buildSecretPathListing(rows, "");
expect(listing.folders.map((folder) => folder.name)).toEqual(["env2", "env10"]);
expect(listing.secrets.map(getSecretPathRowName)).toEqual(["key2", "key10"]);
});
it("preserves encoded and special characters in segments and breadcrumbs", () => {
const row = companyRow("special", "team & ops/100%/api?key=a%2Fb");
expect(buildSecretPathListing([row], "team & ops").folders).toEqual([
{
name: "100%",
path: "team & ops/100%",
secretCount: 1,
folderCount: 0,
},
]);
expect(buildSecretPathBreadcrumbs("/team & ops//100%/api?key=a%2Fb/")).toEqual([
{ name: "team & ops", path: "team & ops" },
{ name: "100%", path: "team & ops/100%" },
{ name: "api?key=a%2Fb", path: "team & ops/100%/api?key=a%2Fb" },
]);
});
it("computes recursive secret and folder counts from the provided rows", () => {
const allRows = [
companyRow("direct", "dev/direct"),
companyRow("a", "dev/a/token"),
companyRow("b", "dev/b/token"),
companyRow("deep", "dev/b/deep/token"),
companyRow("filtered", "dev/c/hidden"),
];
const filteredRows = allRows.filter((row) => row.id !== "filtered");
expect(buildSecretPathListing(filteredRows, "").folders).toEqual([
{ name: "dev", path: "dev", secretCount: 4, folderCount: 3 },
]);
expect(buildSecretPathListing(filteredRows, "dev")).toEqual({
folders: [
{ name: "a", path: "dev/a", secretCount: 1, folderCount: 0 },
{ name: "b", path: "dev/b", secretCount: 2, folderCount: 1 },
],
secrets: [allRows[0]],
});
});
});
describe("validateSecretFolderSegment", () => {
it("rejects empty and slash-containing folder segments", () => {
expect(validateSecretFolderSegment(" ")).toBe("Folder name is required.");
expect(validateSecretFolderSegment("dev/prod")).toBe(
"Folder name cannot contain slashes.",
);
});
it("accepts trimmed and special-character segment names", () => {
expect(validateSecretFolderSegment(" dev tools ")).toBeNull();
expect(validateSecretFolderSegment("100% & encoded%2Fvalue")).toBeNull();
});
});