diff --git a/apps/dokploy/__test__/env/vault.test.ts b/apps/dokploy/__test__/env/vault.test.ts index 98fd7e4d9..eff5634ef 100644 --- a/apps/dokploy/__test__/env/vault.test.ts +++ b/apps/dokploy/__test__/env/vault.test.ts @@ -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()) diff --git a/packages/server/src/utils/vault/infisical.ts b/packages/server/src/utils/vault/infisical.ts index 9af3bca2f..7696d15ab 100644 --- a/packages/server/src/utils/vault/infisical.ts +++ b/packages/server/src/utils/vault/infisical.ts @@ -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 = {}; + // 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; }