diff --git a/src/api/auth.ts b/src/api/auth.ts index af5ebd5..6ef9e3e 100644 --- a/src/api/auth.ts +++ b/src/api/auth.ts @@ -237,100 +237,102 @@ export class Auth { .replace(/'/g, "'"); } - private authErrorResponse(message: string): Response { - const html = ` + // The Enkryptify wordmark logo. The mark keeps the brand blue (#2B7FFF) and + // the wordmark uses the app foreground (#f5f5f5) so it matches the dashboard. + private logoSvg(): string { + return ``; + } + + // Shared page chrome for the browser-facing success/error states. Mirrors + // the app's /oauth/authorize screen: dark brutalist theme, Source Sans 3 / + // JetBrains Mono, centered logo above an animated state icon and two lines + // of copy. Inline HTML/CSS only — no React, no build step. + private renderAuthPage(params: { + state: "success" | "error"; + documentTitle: string; + title: string; + subtitle: string; + hint: string; + autoClose?: boolean; + }): string { + const { state, documentTitle, title, subtitle, hint, autoClose } = params; + + const icon = + state === "success" + ? `` + : ``; + + const autoCloseScript = autoClose ? `` : ""; + + return ` -Sign-in failed — Enkryptify +${documentTitle} - + -
- -
-
-
-
-

We couldn't sign you in

-

${this.escapeHtml(message)}

-
-

Close this tab and try again from your terminal.

-
+
+ + ${icon} +
+

${title}

+

${subtitle}

+

${hint}

+${autoCloseScript} `; + } + + private authErrorResponse(message: string): Response { + const html = this.renderAuthPage({ + state: "error", + documentTitle: "Sign-in failed — Enkryptify", + title: "We couldn't sign you in", + subtitle: this.escapeHtml(message), + hint: "Close this tab and try again from your terminal", + }); return new Response(html, { status: 400, headers: { "Content-Type": "text/html" } }); } private authSuccessResponse(): Response { - const html = ` - - - - -Signed in — Enkryptify - - - - - - - -
- -
-
-
-
-

You're all set

-

Signed in to Enkryptify. Head back to your terminal.

-
-

This tab will close automatically.

-
-
-
- - -`; + const html = this.renderAuthPage({ + state: "success", + documentTitle: "Signed in — Enkryptify", + title: "You're all set", + subtitle: "Signed in to Enkryptify. Head back to your terminal.", + hint: "This tab will close automatically", + autoClose: true, + }); return new Response(html, { status: 200, headers: { "Content-Type": "text/html" } }); } private async exchangeCodeForToken(code: string, codeVerifier: string): Promise { diff --git a/src/api/client.ts b/src/api/client.ts index 201bc56..7ec831d 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -22,6 +22,17 @@ export type RunOptions = { [key: string]: string | undefined; }; +// Result of the interactive configure flow. The explicit status lets the +// caller honor the user's choice instead of inferring intent from a raw +// ProjectConfig: "configured" means a new/overwritten setup was built and +// should be persisted; "kept" means the user declined to change anything and +// nothing should be persisted. +export type ConfigureOutcome = { + status: "configured" | "kept"; + scope: ConfigureScope; + config: ProjectConfig; +}; + type Workspace = { id: string; name: string; @@ -98,12 +109,29 @@ class EnkryptifyClient { await this.auth.login(options); } - async configure(options: string, configureOptions?: { scope?: ConfigureScope }): Promise { + async configure(options: string, configureOptions?: { scope?: ConfigureScope }): Promise { + const scope = configureOptions?.scope ?? "path"; const setup = await config.getConfigure(options, configureOptions); if (setup) { const overwrite = await confirm("Setup already exists. Overwrite?"); if (!overwrite) { - return setup; + return { status: "kept", scope, config: setup }; + } + } else if (scope === "git") { + // A path-only setup for this same directory would shadow the Git + // setup. Detect it and confirm replacing it before continuing. + // getConfigure returns null when no path setup exists, so any thrown + // error here is a genuine read failure and should propagate. + const pathSetup = await config.getConfigure(options, { scope: "path" }); + if (pathSetup) { + const replace = await confirm( + "A path-only setup already exists for this directory. Replace it with a Git-repository setup?", + ); + if (!replace) { + // The user declined: keep the existing path setup untouched + // and report the effective scope so nothing is persisted. + return { status: "kept", scope: "path", config: pathSetup }; + } } } @@ -238,7 +266,7 @@ class EnkryptifyClient { `Setup completed successfully! Workspace: ${selectedWorkspace.name}, Project: ${selectedProject.name}, Environment: ${environmentName}`, ); - return projectConfig; + return { status: "configured", scope, config: projectConfig }; } async run(config: ProjectConfig, options?: RunOptions): Promise { diff --git a/src/cmd/configure.ts b/src/cmd/configure.ts index 2f5e533..02c294a 100644 --- a/src/cmd/configure.ts +++ b/src/cmd/configure.ts @@ -15,11 +15,20 @@ const GIT_SCOPE_LABEL = "Git repository (recommended)"; const PATH_SCOPE_LABEL = "This path only"; async function resolveConfigureScope(projectPath: string, options: ConfigureCommandOptions): Promise { + const gitRepo = await getGitRepoInfo(projectPath); + if (options.git) { + if (!gitRepo) { + throw new CLIError( + "No Git repository found.", + "The current directory is not inside a Git repository.", + 'Run "ek configure" without --git to set up this path, or run it from inside a Git repository.', + "/cli/troubleshooting#configuration", + ); + } return "git"; } - const gitRepo = await getGitRepoInfo(projectPath); if (!gitRepo) { return "path"; } @@ -41,11 +50,15 @@ export async function configure(options: ConfigureCommandOptions = {}): Promise< const projectPath = process.cwd(); const scope = await resolveConfigureScope(projectPath, options); - const projectConfig = await client.configure(projectPath, { scope }); + const outcome = await client.configure(projectPath, { scope }); - await config.createConfigure(projectPath, projectConfig, { scope }); + // Only persist when a new/overwritten setup was actually built. When the + // user declined to change anything ("kept"), leave the existing config as-is. + if (outcome.status === "configured") { + await config.createConfigure(projectPath, outcome.config, { scope }); + } - return projectConfig; + return outcome.config; } export function registerConfigureCommand(program: Command) { diff --git a/src/lib/config.ts b/src/lib/config.ts index b2a5879..1407793 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -1,5 +1,5 @@ import { CLIError } from "@/lib/errors"; -import { getGitRepoInfo } from "@/lib/git"; +import { type GitRepoInfo, getGitRepoInfo } from "@/lib/git"; import { logger } from "@/lib/logger"; import * as fs from "fs/promises"; import * as os from "os"; @@ -197,13 +197,15 @@ async function isAuthenticated(): Promise { return config.providers?.["enkryptify"] != null; } -async function getSetupKey(projectPath: string, scope: ConfigureScope): Promise { +// `gitRepo` may be passed in to avoid re-running git when the caller already +// resolved it. `undefined` means "not resolved yet", `null` means "resolved, not a repo". +async function getSetupKey(projectPath: string, scope: ConfigureScope, gitRepo?: GitRepoInfo | null): Promise { if (scope === "path") { return path.resolve(projectPath); } - const gitRepo = await getGitRepoInfo(projectPath); - if (!gitRepo) { + const repo = gitRepo === undefined ? await getGitRepoInfo(projectPath) : gitRepo; + if (!repo) { throw new CLIError( "No Git repository found.", "The current directory is not inside a Git repository.", @@ -212,7 +214,7 @@ async function getSetupKey(projectPath: string, scope: ConfigureScope): Promise< ); } - return gitRepo.setupKey; + return repo.setupKey; } async function createConfigureWithOptions( @@ -221,13 +223,32 @@ async function createConfigureWithOptions( options: ConfigureOptions = {}, ): Promise { const config = await loadConfig(); - const setupKey = await getSetupKey(projectPath, options.scope ?? "path"); + const scope = options.scope ?? "path"; + const gitRepo = await getGitRepoInfo(projectPath); + const setupKey = await getSetupKey(projectPath, scope, gitRepo); if (!config.setups) config.setups = {}; const { path: _, ...setupData } = projectConfig; config.setups[setupKey] = setupData; + // When switching this directory to Git scope, drop the now-redundant + // path-only setup for the same directory. Otherwise the path entry would + // shadow the Git setup in findProjectConfig and the switch would silently + // have no effect. We only do this for the same directory, so unrelated + // nested path setups elsewhere in the repo are preserved. Both the resolved + // and the symlink-canonical path are removed so a stale setup saved under a + // symlinked alias (e.g. /var vs /private/var) is also cleared. + if (scope === "git") { + const pathKey = path.resolve(projectPath); + const realPathKey = await fs.realpath(projectPath).catch(() => pathKey); + for (const key of new Set([pathKey, realPathKey])) { + if (key !== setupKey && config.setups[key]) { + delete config.setups[key]; + } + } + } + await saveConfig(config); } @@ -245,23 +266,49 @@ async function hasAnyProject(): Promise { async function findProjectConfig(startPath: string): Promise { const config = await loadConfig(); + const setups = config.setups ?? {}; + const gitRepo = await getGitRepoInfo(startPath); + + // Resolve the repo root through symlinks so the boundary comparison below + // is reliable (e.g. macOS /var vs /private/var). + let repoRootReal: string | null = null; + if (gitRepo) { + repoRootReal = await fs.realpath(gitRepo.root).catch(() => path.resolve(gitRepo.root)); + } + let currentPath = path.resolve(startPath); const root = path.parse(currentPath).root; - while (currentPath !== root) { - const normalizedPath = path.resolve(currentPath); - const setup = config.setups?.[normalizedPath]; + // Walk up from the start directory. A path-scoped setup at or below the + // repo root is more specific and wins. Once we reach the repo root, prefer + // the git-scoped setup over any ancestor path setup above the repo, so a + // git-configured repo is never shadowed by an unrelated parent directory. + while (true) { + const setup = setups[currentPath]; if (setup) { - return { path: normalizedPath, ...setup }; + return { path: currentPath, ...setup }; } + + if (gitRepo && repoRootReal) { + const currentReal = await fs.realpath(currentPath).catch(() => currentPath); + if (currentReal === repoRootReal) { + const gitSetup = setups[gitRepo.setupKey]; + if (gitSetup) { + return { path: gitRepo.setupKey, ...gitSetup }; + } + } + } + + if (currentPath === root) break; currentPath = path.dirname(currentPath); } - const gitRepo = await getGitRepoInfo(startPath); + // Safety net: resolve the git setup even if the repo root wasn't on the + // resolved walk path (e.g. symlinked working directories). if (gitRepo) { - const setup = config.setups?.[gitRepo.setupKey]; - if (setup) { - return { path: gitRepo.setupKey, ...setup }; + const gitSetup = setups[gitRepo.setupKey]; + if (gitSetup) { + return { path: gitRepo.setupKey, ...gitSetup }; } } diff --git a/src/lib/secretCache.ts b/src/lib/secretCache.ts index 86cec48..250030e 100644 --- a/src/lib/secretCache.ts +++ b/src/lib/secretCache.ts @@ -28,15 +28,6 @@ function buildCacheKey(workspaceSlug: string, projectSlug: string, environmentKe return `${CACHE_KEY_PREFIX}${workspaceSlug}/${projectSlug}/${environmentKey}`; } -function decode(encoded: string): CacheEntry | null { - try { - const json = atob(encoded); - return JSON.parse(json) as CacheEntry; - } catch { - return null; - } -} - async function readCache(key: string): Promise { try { return await secureStore.getSecretCacheEntry(key); @@ -54,10 +45,6 @@ async function writeCache(key: string, secrets: Secret[]): Promise { } } -async function readLegacyCache(key: string): Promise { - return await secureStore.migrateLegacySecretCacheEntry(key, decode); -} - export async function fetchSecretsWithCache( config: ProjectConfig, runOptions: { env?: string; project?: string }, @@ -100,7 +87,7 @@ export async function fetchSecretsWithCache( // --offline: use cache only, never fetch if (cacheOptions.offline) { - const cached = (await readCache(cacheKey)) ?? (await readLegacyCache(cacheKey)); + const cached = await readCache(cacheKey); if (!cached) { throw new CLIError( "No cached secrets available.", @@ -112,7 +99,7 @@ export async function fetchSecretsWithCache( } // Normal mode: check TTL, fetch if stale, fallback to cache on error - const cached = (await readCache(cacheKey)) ?? (await readLegacyCache(cacheKey)); + const cached = await readCache(cacheKey); if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) { return { secrets: cached.secrets, fromCache: true, cacheReason: "ttl" }; } diff --git a/src/lib/secureStore.ts b/src/lib/secureStore.ts index 0e0120f..59018a2 100644 --- a/src/lib/secureStore.ts +++ b/src/lib/secureStore.ts @@ -138,30 +138,4 @@ export const secureStore = { }; }); }, - - async migrateLegacySecretCacheEntry( - cacheKey: string, - decode: (raw: string) => StoredSecretCacheEntry | null, - ): Promise { - try { - const raw = await keyring.get(cacheKey); - if (!raw) return null; - - const entry = decode(raw); - if (!entry) return null; - - await updateStore((store) => { - store.secretCache = { - ...(store.secretCache ?? {}), - [cacheKey]: entry, - }; - }); - - await keyring.delete(cacheKey).catch(() => undefined); - - return entry; - } catch { - return null; - } - }, }; diff --git a/tests/integration/config.test.ts b/tests/integration/config.test.ts index fff5990..d641396 100644 --- a/tests/integration/config.test.ts +++ b/tests/integration/config.test.ts @@ -259,6 +259,73 @@ describe("config (integration)", () => { expect(found.path).toBe(path.resolve(childPath)); }); + it("does not let an ancestor path setup shadow a git-scoped repo", async () => { + const ancestorPath = path.join(tmpDir, "ancestor"); + const repoPath = path.join(ancestorPath, "repo"); + const subdirPath = path.join(repoPath, "src"); + fs.mkdirSync(subdirPath, { recursive: true }); + execFileSync("git", ["init"], { cwd: repoPath }); + + // Ancestor directory configured with path scope. + await config.createConfigure(ancestorPath, { + path: ancestorPath, + workspace_slug: "ws-ancestor", + project_slug: "proj-ancestor", + environment_id: "env-ancestor", + }); + + // The repo itself configured with git scope. + await config.createConfigure( + repoPath, + { + path: repoPath, + workspace_slug: "ws-git", + project_slug: "proj-git", + environment_id: "env-git", + }, + { scope: "git" }, + ); + + const found = await config.findProjectConfig(subdirPath); + expect(found.workspace_slug).toBe("ws-git"); + expect(found.path).toMatch(/^git:/); + }); + + it("switching a directory from path to git scope removes the stale path setup", async () => { + const repoPath = path.join(tmpDir, "switch-scope"); + fs.mkdirSync(repoPath, { recursive: true }); + execFileSync("git", ["init"], { cwd: repoPath }); + + // First configured as path scope. + await config.createConfigure(repoPath, { + path: repoPath, + workspace_slug: "ws-path", + project_slug: "proj-path", + environment_id: "env-path", + }); + + // Re-configured as git scope for the same directory. + await config.createConfigure( + repoPath, + { + path: repoPath, + workspace_slug: "ws-git", + project_slug: "proj-git", + environment_id: "env-git", + }, + { scope: "git" }, + ); + + const stored = JSON.parse(fs.readFileSync(configPath, "utf-8")); + const keys = Object.keys(stored.setups); + expect(keys).toHaveLength(1); + expect(keys[0]).toMatch(/^git:/); + + const found = await config.findProjectConfig(repoPath); + expect(found.workspace_slug).toBe("ws-git"); + expect(found.path).toMatch(/^git:/); + }); + it("throws CLIError when no config found", async () => { const randomPath = path.join(tmpDir, "no-config-here"); fs.mkdirSync(randomPath, { recursive: true }); diff --git a/tests/integration/configure-command.test.ts b/tests/integration/configure-command.test.ts index 8cf0bc9..59b1678 100644 --- a/tests/integration/configure-command.test.ts +++ b/tests/integration/configure-command.test.ts @@ -36,7 +36,11 @@ describe("configure command", () => { vi.spyOn(process, "cwd").mockReturnValue("/tmp/repo"); vi.mocked(config.isAuthenticated).mockResolvedValue(true); vi.mocked(config.createConfigure).mockResolvedValue(undefined); - vi.mocked(client.configure).mockResolvedValue(FAKE_PROJECT_CONFIG); + vi.mocked(client.configure).mockResolvedValue({ + status: "configured", + scope: "path", + config: FAKE_PROJECT_CONFIG, + }); }); afterEach(() => { @@ -44,6 +48,12 @@ describe("configure command", () => { }); it("uses git scope when --git is provided", async () => { + vi.mocked(getGitRepoInfo).mockResolvedValue({ + root: "/tmp/repo", + commonDir: "/tmp/repo/.git", + setupKey: "git:/tmp/repo/.git", + }); + await configure({ git: true }); expect(selectName).not.toHaveBeenCalled(); @@ -51,6 +61,14 @@ describe("configure command", () => { expect(config.createConfigure).toHaveBeenCalledWith("/tmp/repo", FAKE_PROJECT_CONFIG, { scope: "git" }); }); + it("throws when --git is used outside a git repository (before any API call)", async () => { + vi.mocked(getGitRepoInfo).mockResolvedValue(null); + + await expect(configure({ git: true })).rejects.toThrow("No Git repository found."); + expect(client.configure).not.toHaveBeenCalled(); + expect(config.createConfigure).not.toHaveBeenCalled(); + }); + it("asks for setup scope inside a git repo and defaults to git option", async () => { vi.mocked(getGitRepoInfo).mockResolvedValue({ root: "/tmp/repo", @@ -78,4 +96,22 @@ describe("configure command", () => { expect(client.configure).toHaveBeenCalledWith("/tmp/repo", { scope: "path" }); expect(config.createConfigure).toHaveBeenCalledWith("/tmp/repo", FAKE_PROJECT_CONFIG, { scope: "path" }); }); + + it('does not persist when the configure flow reports "kept" (user declined)', async () => { + vi.mocked(getGitRepoInfo).mockResolvedValue({ + root: "/tmp/repo", + commonDir: "/tmp/repo/.git", + setupKey: "git:/tmp/repo/.git", + }); + vi.mocked(selectName).mockResolvedValue("Git repository (recommended)"); + vi.mocked(client.configure).mockResolvedValue({ + status: "kept", + scope: "path", + config: FAKE_PROJECT_CONFIG, + }); + + await configure(); + + expect(config.createConfigure).not.toHaveBeenCalled(); + }); }); diff --git a/tests/integration/configure-flow.test.ts b/tests/integration/configure-flow.test.ts index a86310c..156ff62 100644 --- a/tests/integration/configure-flow.test.ts +++ b/tests/integration/configure-flow.test.ts @@ -59,10 +59,11 @@ describe("client.configure() flow (integration)", () => { it("returns ProjectConfig with correct workspace_slug, project_slug, environment_id", async () => { const result = await client.configure("/tmp/test"); - expect(result.workspace_slug).toBe("test-workspace"); - expect(result.project_slug).toBe("test-project"); - expect(result.environment_id).toBe("env-test-123"); - expect(result.path).toBe("/tmp/test"); + expect(result.status).toBe("configured"); + expect(result.config.workspace_slug).toBe("test-workspace"); + expect(result.config.project_slug).toBe("test-project"); + expect(result.config.environment_id).toBe("env-test-123"); + expect(result.config.path).toBe("/tmp/test"); }); it("fetches projects for the selected workspace (correct slug in URL)", async () => { @@ -172,6 +173,31 @@ describe("client.configure() flow (integration)", () => { expect(config.getConfigure).toHaveBeenCalledWith("/tmp/test", { scope: "git" }); }); + it("keeps the path setup when the user declines the git replacement", async () => { + const pathSetup = { + path: "/tmp/test", + workspace_slug: "path-ws", + project_slug: "path-proj", + environment_id: "path-env", + }; + // No git-scoped setup yet, but a path-scoped one exists for this dir. + vi.mocked(config.getConfigure).mockImplementation(async (_path: string, options?: { scope?: string }) => + options?.scope === "path" ? pathSetup : null, + ); + vi.mocked(confirm).mockResolvedValue(false); + + const result = await client.configure("/tmp/test", { scope: "git" }); + + expect(confirm).toHaveBeenCalledWith( + "A path-only setup already exists for this directory. Replace it with a Git-repository setup?", + ); + expect(result.status).toBe("kept"); + expect(result.scope).toBe("path"); + expect(result.config).toBe(pathSetup); + // Declining must not start the configure flow. + expect(http.get).not.toHaveBeenCalled(); + }); + it("returns existing config when user declines overwrite", async () => { const existingConfig = { path: "/tmp/test", @@ -184,7 +210,8 @@ describe("client.configure() flow (integration)", () => { const result = await client.configure("/tmp/test"); - expect(result).toBe(existingConfig); + expect(result.status).toBe("kept"); + expect(result.config).toBe(existingConfig); // Should not have fetched workspaces expect(http.get).not.toHaveBeenCalled(); }); diff --git a/tests/integration/secret-cache.test.ts b/tests/integration/secret-cache.test.ts index 99b9765..40083d4 100644 --- a/tests/integration/secret-cache.test.ts +++ b/tests/integration/secret-cache.test.ts @@ -259,9 +259,8 @@ describe("fetchSecretsWithCache (integration)", () => { // --- Corrupted cache --- it("corrupted cache data returns null (does not crash)", async () => { - // Manually write garbage into the keyring at the expected cache key - const cacheKey = `secret-cache:${FAKE_PROJECT_CONFIG.workspace_slug}/${FAKE_PROJECT_CONFIG.project_slug}/${FAKE_PROJECT_CONFIG.environment_id}`; - await mockKeyring.set(cacheKey, "not-valid-base64-!!!@@@"); + // Manually write garbage into the unified secure-store item + await mockKeyring.set("enkryptify", "not-valid-json-!!!@@@"); // Should treat corrupted cache as a miss and call fetcher const fetcher = makeFetcher(); @@ -271,7 +270,8 @@ describe("fetchSecretsWithCache (integration)", () => { expect(result.secrets).toEqual(FAKE_SECRETS); }); - it("migrates legacy per-cache key into unified secure store", async () => { + it("never reads or deletes legacy per-cache keychain items", async () => { + // A leftover legacy item from before the unified-store migration. const cacheKey = `secret-cache:${FAKE_PROJECT_CONFIG.workspace_slug}/${FAKE_PROJECT_CONFIG.project_slug}/${FAKE_PROJECT_CONFIG.environment_id}`; await mockKeyring.set( cacheKey, @@ -284,21 +284,11 @@ describe("fetchSecretsWithCache (integration)", () => { const fetcher = makeFetcher(); const result = await fetchSecretsWithCache(FAKE_PROJECT_CONFIG, defaultRunOptions, {}, fetcher); - expect(fetcher).not.toHaveBeenCalled(); - expect(result.fromCache).toBe(true); - expect(result.cacheReason).toBe("ttl"); + // The legacy item is ignored: we fetch fresh instead of reading it, + // and the legacy item is left untouched (no keychain access -> no prompt). + expect(fetcher).toHaveBeenCalledOnce(); + expect(result.fromCache).toBe(false); expect(result.secrets).toEqual(FAKE_SECRETS); - expect(await mockKeyring.get(cacheKey)).toBeNull(); - - const stored = await mockKeyring.get("enkryptify"); - expect(JSON.parse(stored!)).toEqual({ - version: 1, - secretCache: { - [cacheKey]: { - secrets: FAKE_SECRETS, - timestamp: NOW, - }, - }, - }); + expect(await mockKeyring.get(cacheKey)).toBe(legacyEncode({ secrets: FAKE_SECRETS, timestamp: NOW })); }); }); diff --git a/tests/integration/secure-store.test.ts b/tests/integration/secure-store.test.ts index 93fc1c4..e5cea99 100644 --- a/tests/integration/secure-store.test.ts +++ b/tests/integration/secure-store.test.ts @@ -97,38 +97,14 @@ describe("secureStore", () => { }); }); - it("migrates a legacy per-cache key into the unified item", async () => { + it("leaves legacy per-cache keychain items untouched", async () => { + // The unified store no longer reads or migrates legacy per-key items, + // so any leftover legacy entry stays exactly as-is (never accessed). const cacheKey = "secret-cache:test-workspace/test-project/env-test-123"; - const entry = { secrets: FAKE_SECRETS, timestamp: 1700000000000 }; - await mockKeyring.set(cacheKey, encodeLegacyCache(entry)); - - const migrated = await secureStore.migrateLegacySecretCacheEntry(cacheKey, (raw) => { - try { - return JSON.parse(Buffer.from(raw, "base64").toString("utf8")); - } catch { - return null; - } - }); - - expect(migrated).toEqual(entry); - expect(await mockKeyring.get(cacheKey)).toBeNull(); - const raw = await mockKeyring.get("enkryptify"); - expect(JSON.parse(raw!)).toEqual({ - version: 1, - secretCache: { - [cacheKey]: entry, - }, - }); - }); - - it("ignores corrupted legacy per-cache data", async () => { - const cacheKey = "secret-cache:test-workspace/test-project/env-test-123"; - await mockKeyring.set(cacheKey, "not-valid-base64-!!!@@@"); - - const migrated = await secureStore.migrateLegacySecretCacheEntry(cacheKey, () => null); + const legacy = encodeLegacyCache({ secrets: FAKE_SECRETS, timestamp: 1700000000000 }); + await mockKeyring.set(cacheKey, legacy); - expect(migrated).toBeNull(); - expect(await mockKeyring.get("enkryptify")).toBeNull(); - expect(await mockKeyring.get(cacheKey)).toBe("not-valid-base64-!!!@@@"); + await expect(secureStore.getSecretCacheEntry(cacheKey)).resolves.toBeNull(); + expect(await mockKeyring.get(cacheKey)).toBe(legacy); }); });