fix(vault): read Infisical secrets pulled in through an import

Fixes #5413. A secret brought into a folder with "Import Secrets" was reported
as not found. Two reasons, and the first is easy to miss:

The list endpoint takes the flag in **snake_case**. `includeImports` is
silently ignored — the request still returns 200, with `imports` present but
empty — so the imported secret looked like it simply did not exist. Measured
against app.infisical.com with a registered import (confirmed via
GET /api/v1/secret-imports):

  expandSecretReferences=true                        imports[] empty
  expandSecretReferences=true&includeImports=true    imports[] empty
  expandSecretReferences=true&include_imports=true   imports[] has the secret

Second, imported secrets never appear in `secrets` — they come back in a
separate `imports` array, one entry per source path, which the client did not
read at all.

Imported entries are merged before the folder's own, so a name defined in both
resolves to the local value, matching how Infisical resolves it.

Also measured, for whoever looks next: `/api/v4/secrets` returns imports with
no flag at all, and single-secret reads (`/raw/{name}`) never see an imported
key — 404 on v3, no such route on v4. So a folder listing is the only way to
reach them.

vault.test.ts: 57 passed. Reverting the fix fails the new merge test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Artur Spatari 2026-09-10 10:26:52 +03:00
parent 88118cdb3f
commit 0c1dfe978c
2 changed files with 58 additions and 0 deletions

View File

@ -472,6 +472,50 @@ describe("infisical client", () => {
expect(params.get("secretPath")).toBe("/frontend");
});
it("asks for imported secrets and merges them", async () => {
mockFetch.mockResolvedValueOnce(loginResponse()).mockResolvedValueOnce(
jsonResponse({
secrets: [],
imports: [
{
secretPath: "/shared",
secrets: [
{ secretKey: "DB_URL", secretValue: "postgres://imported" },
],
},
],
}),
);
const result = await infisicalClient.getSecrets(config, ["DB_URL"]);
expect(result).toEqual({ DB_URL: "postgres://imported" });
const [listUrl] = mockFetch.mock.calls[1] as [string];
// snake_case on purpose: `includeImports` is silently ignored and the
// response comes back with an empty `imports` array.
expect(new URL(listUrl).searchParams.get("include_imports")).toBe("true");
});
it("lets a folder's own secret win over an imported one of the same name", async () => {
mockFetch.mockResolvedValueOnce(loginResponse()).mockResolvedValueOnce(
jsonResponse({
secrets: [{ secretKey: "DB_URL", secretValue: "postgres://local" }],
imports: [
{
secretPath: "/shared",
secrets: [
{ secretKey: "DB_URL", secretValue: "postgres://imported" },
],
},
],
}),
);
const result = await infisicalClient.getSecrets(config, ["DB_URL"]);
expect(result).toEqual({ DB_URL: "postgres://local" });
});
it("throws a clear error for a missing secret", async () => {
mockFetch
.mockResolvedValueOnce(loginResponse())

View File

@ -79,6 +79,12 @@ const readPath = async (
// still reports success. Single secrets read via /raw/{name} expand by
// default, which makes the difference easy to miss in the UI.
expandSecretReferences: "true",
// Secrets pulled in through "Import Secrets" are not part of `secrets`:
// they come back in a separate `imports` array, and only when this is
// asked for. Note the snake_case — `includeImports` is silently ignored
// (the request still returns 200 with an empty `imports`), which is why
// an imported secret looked like it simply did not exist.
include_imports: "true",
});
const response = await vaultFetch(
`${baseUrl(config)}/api/v3/secrets/raw?${params.toString()}`,
@ -93,9 +99,17 @@ const readPath = async (
const body = (await response.json()) as {
secrets?: { secretKey: string; secretValue: string }[];
imports?: { secrets?: { secretKey: string; secretValue: string }[] }[];
};
const secrets: Record<string, string> = {};
// Imported first, then the folder's own: Infisical resolves a name defined
// in both in favour of the local one, so writing local last preserves that.
for (const imported of body.imports ?? []) {
for (const secret of imported.secrets ?? []) {
secrets[secret.secretKey] = secret.secretValue;
}
}
for (const secret of body.secrets ?? []) {
secrets[secret.secretKey] = secret.secretValue;
}