From 0f5f0989a02ffca8130836a4140047dcb0f7f3b3 Mon Sep 17 00:00:00 2001 From: Whit Waldo Date: Sun, 13 Sep 2026 18:41:01 -0500 Subject: [PATCH 1/2] Adding support for a secret-store harness using local files Signed-off-by: Whit Waldo --- README.md | 47 +++- src/Constants.ts | 1 + src/DaprContainer.ts | 34 +++ src/SecretStore.test.ts | 221 +++++++++++++++++++ src/SecretStore.ts | 200 +++++++++++++++++ src/SecretStoreHarness.test.ts | 190 ++++++++++++++++ src/SecretStoreHarness.ts | 199 +++++++++++++++++ src/__fixtures__/dapr-resources/secrets.json | 7 + src/index.ts | 2 + 9 files changed, 900 insertions(+), 1 deletion(-) create mode 100644 src/SecretStore.test.ts create mode 100644 src/SecretStore.ts create mode 100644 src/SecretStoreHarness.test.ts create mode 100644 src/SecretStoreHarness.ts create mode 100644 src/__fixtures__/dapr-resources/secrets.json diff --git a/README.md b/README.md index cefe131..8fd72ab 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ The Testcontainers Dapr module for NodeJS enables local development and testing providing a DaprContainer that sets up a Dapr sidecar instance. This container provides an in-memory implementation of Dapr APIs by default, facilitating testing without requiring a full Dapr installation or external dependencies. -A usage example can be found in [`src/DaprContainer.test.ts`](https://github.com/dapr/testcontainer-node/blob/main/src/DaprContainer.test.ts) and [`src/WorkflowHarness.test.ts`](https://github.com/dapr/testcontainer-node/blob/main/src/WorkflowHarness.test.ts). +A usage example can be found in [`src/DaprContainer.test.ts`](https://github.com/dapr/testcontainer-node/blob/main/src/DaprContainer.test.ts), [`src/WorkflowHarness.test.ts`](https://github.com/dapr/testcontainer-node/blob/main/src/WorkflowHarness.test.ts) and [`src/SecretStoreHarness.test.ts`](https://github.com/dapr/testcontainer-node/blob/main/src/SecretStoreHarness.test.ts). ## Using the library @@ -56,6 +56,51 @@ const state = await client.waitForWorkflowCompletion(instanceId); await harness.stop(); ``` +## Dapr Secrets Testing + +You can use `SecretStoreHarness` or `.withSecretStore()` on `DaprContainer` to test the Dapr Secrets building block using a +local file secret store (`secretstores.local.file`). The harness writes both the component YAML and its backing JSON +secrets file into the container: + +```typescript +import { SecretStoreHarness } from "@dapr/testcontainer-node"; + +const harness = new SecretStoreHarness({ + secrets: { + secret1: "value1", + connection: { username: "admin", password: "s3cr3t" }, + }, +}); +await harness.start(); + +await harness.getSecretValue("secret1"); // "value1" +await harness.getSecretValue("connection:username"); // "admin" (nested keys are flattened) +await harness.getBulkSecrets(); + +await harness.stop(); +``` + +Secrets can also be loaded from an existing JSON file on the host, and the nested separator is configurable: + +```typescript +const harness = new SecretStoreHarness({ + secretStoreName: "my-secrets", + secretsFilePath: "./test/secrets.json", + nestedSeparator: ".", +}); +``` + +To register one or more secret stores directly on a `DaprContainer`: + +```typescript +import { DaprContainer } from "@dapr/testcontainer-node"; + +const dapr = new DaprContainer() + .withNetwork(network) + .withSecretStore({ secrets: { alpha: "one" } }) + .withSecretStore({ name: "second-store", secrets: { beta: "two" } }); +``` + ## Versions This library follows [Semantic Versioning](https://semver.org/). diff --git a/src/Constants.ts b/src/Constants.ts index 046b998..c04e711 100644 --- a/src/Constants.ts +++ b/src/Constants.ts @@ -69,4 +69,5 @@ export const DaprComponentNames = { ConversationComponentName: "conversation", CryptographyComponentName: "cryptography", DistributedLockComponentName: "distributed-lock", + SecretStoreComponentName: "localsecretstore", } as const; diff --git a/src/DaprContainer.ts b/src/DaprContainer.ts index f91fe21..39071d2 100644 --- a/src/DaprContainer.ts +++ b/src/DaprContainer.ts @@ -38,6 +38,7 @@ import { DaprPlacementContainer } from "./DaprPlacementContainer"; import { DaprSchedulerContainer } from "./DaprSchedulerContainer"; import { HttpEndpoint } from "./HttpEndpoint"; import { REDIS_DEFAULT_PORT, RedisContainer } from "./RedisContainer"; +import { LocalFileSecretStoreOptions, ResolvedLocalFileSecretStore, resolveLocalFileSecretStore } from "./SecretStore"; import { Subscription } from "./Subscription"; export { @@ -91,6 +92,7 @@ export class DaprContainer extends GenericContainer { private components: Component[] = []; private subscriptions: Subscription[] = []; private httpEndpoints: HttpEndpoint[] = []; + private secretStores: ResolvedLocalFileSecretStore[] = []; constructor(image: string = getDaprRuntimeImage()) { super(image); @@ -245,6 +247,14 @@ export class DaprContainer extends GenericContainer { } } + for (const secretStore of this.secretStores) { + log.info("> Secrets file: \n"); + log.info(`\t${secretStore.containerSecretsFilePath}\n`); + this.withCopyContentToContainer([ + { content: secretStore.secretsJson, target: secretStore.containerSecretsFilePath }, + ]); + } + for (const component of this.components) { const componentYaml = component.toYaml(); log.info("> Component YAML: \n"); @@ -432,6 +442,30 @@ export class DaprContainer extends GenericContainer { return this; } + /** + * Adds a local file-based secret store to the container, writing both the + * `secretstores.local.file` component and its backing JSON secrets file. + * + * @param options Configuration options for the secret store. + * @return This container. + */ + withSecretStore(options: LocalFileSecretStoreOptions = {}): this { + const resolved = resolveLocalFileSecretStore(options); + if (this.secretStores.some((s) => s.name === resolved.name)) { + throw new Error(`A secret store component named "${resolved.name}" has already been registered`); + } + if (this.secretStores.some((s) => s.containerSecretsFilePath === resolved.containerSecretsFilePath)) { + throw new Error(`A secrets file is already mapped to "${resolved.containerSecretsFilePath}"`); + } + this.secretStores.push(resolved); + this.components.push(resolved.component); + return this; + } + + getSecretStores(): ResolvedLocalFileSecretStore[] { + return this.secretStores.slice(); + } + /** * Adds a Dapr component from a YAML file. * @param path Path to the YAML file. diff --git a/src/SecretStore.test.ts b/src/SecretStore.test.ts new file mode 100644 index 0000000..7123b80 --- /dev/null +++ b/src/SecretStore.test.ts @@ -0,0 +1,221 @@ +/* +Copyright 2026 The Dapr Authors +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { Component } from "./Component"; +import { DaprComponentNames } from "./Constants"; +import { DaprContainer } from "./DaprContainer"; +import { + createLocalFileSecretStoreComponent, + createSecretsJson, + DEFAULT_NESTED_SEPARATOR, + DEFAULT_SECRETS, + DEFAULT_SECRETS_CONTAINER_DIRECTORY, + DEFAULT_SECRETS_FILE_NAME, + resolveLocalFileSecretStore, + validateSecrets, + writeSecretsFile, +} from "./SecretStore"; + +const metadataValue = (component: Component, name: string): string | undefined => + component.getMetadata().find((entry) => entry.name === name)?.value; + +describe("SecretStore local file component", () => { + describe("createSecretsJson", () => { + it("serializes the default secrets", () => { + expect(JSON.parse(createSecretsJson())).toEqual(DEFAULT_SECRETS); + }); + + it("serializes nested secrets", () => { + const json = createSecretsJson({ flat: "value", nested: { a: "1", b: "2" } }); + expect(JSON.parse(json)).toEqual({ flat: "value", nested: { a: "1", b: "2" } }); + }); + + it("ends with a trailing newline", () => { + expect(createSecretsJson()).toMatch(/\n$/); + }); + }); + + describe("validateSecrets", () => { + it("accepts flat and nested string maps", () => { + expect(() => validateSecrets({ a: "1", b: { c: "2" } })).not.toThrow(); + }); + + it("rejects non-string leaf values", () => { + expect(() => validateSecrets({ a: 1 } as never)).toThrow(/must be a string or an object/); + }); + + it("rejects nested non-string values", () => { + expect(() => validateSecrets({ a: { b: 5 } } as never)).toThrow(/must be a string value/); + }); + + it("rejects arrays", () => { + expect(() => validateSecrets({ a: ["x"] } as never)).toThrow(/must be a string or an object/); + }); + + it("rejects a non-object secrets map", () => { + expect(() => validateSecrets("nope" as never)).toThrow(/object of key\/value pairs/); + }); + }); + + describe("createLocalFileSecretStoreComponent", () => { + it("uses the documented defaults", () => { + const component = createLocalFileSecretStoreComponent(); + expect(component.name).toBe(DaprComponentNames.SecretStoreComponentName); + expect(component.name).toBe("localsecretstore"); + expect(component.type).toBe("secretstores.local.file"); + expect(component.version).toBe("v1"); + expect(metadataValue(component, "secretsFile")).toBe( + `${DEFAULT_SECRETS_CONTAINER_DIRECTORY}/${DEFAULT_SECRETS_FILE_NAME}` + ); + expect(metadataValue(component, "nestedSeparator")).toBe(DEFAULT_NESTED_SEPARATOR); + expect(metadataValue(component, "multiValued")).toBeUndefined(); + }); + + it("derives a distinct secrets file per named component", () => { + const component = createLocalFileSecretStoreComponent({ name: "other-store" }); + expect(component.name).toBe("other-store"); + expect(metadataValue(component, "secretsFile")).toBe(`${DEFAULT_SECRETS_CONTAINER_DIRECTORY}/other-store.json`); + }); + + it("honors custom separator, multiValued and container path", () => { + const component = createLocalFileSecretStoreComponent({ + nestedSeparator: ".", + multiValued: true, + containerSecretsFilePath: "/custom/path/my-secrets.json", + }); + expect(metadataValue(component, "nestedSeparator")).toBe("."); + expect(metadataValue(component, "multiValued")).toBe("true"); + expect(metadataValue(component, "secretsFile")).toBe("/custom/path/my-secrets.json"); + }); + + it("rejects an empty component name", () => { + expect(() => createLocalFileSecretStoreComponent({ name: " " })).toThrow(/must not be empty/); + }); + + it("round-trips through YAML", () => { + const component = createLocalFileSecretStoreComponent(); + const parsed = Component.fromYaml(component.toYaml()); + expect(parsed.name).toBe(component.name); + expect(parsed.type).toBe("secretstores.local.file"); + expect(parsed.getMetadata()).toEqual(component.getMetadata()); + }); + }); + + describe("resolveLocalFileSecretStore", () => { + it("resolves defaults", () => { + const resolved = resolveLocalFileSecretStore(); + expect(resolved.name).toBe("localsecretstore"); + expect(JSON.parse(resolved.secretsJson)).toEqual(DEFAULT_SECRETS); + expect(resolved.containerSecretsFilePath).toBe( + `${DEFAULT_SECRETS_CONTAINER_DIRECTORY}/${DEFAULT_SECRETS_FILE_NAME}` + ); + expect(metadataValue(resolved.component, "secretsFile")).toBe(resolved.containerSecretsFilePath); + }); + + it("resolves supplied secrets", () => { + const resolved = resolveLocalFileSecretStore({ secrets: { token: "abc" } }); + expect(JSON.parse(resolved.secretsJson)).toEqual({ token: "abc" }); + }); + + it("reads secrets from a file path", () => { + const fixture = path.join(__dirname, "__fixtures__", "dapr-resources", "secrets.json"); + const resolved = resolveLocalFileSecretStore({ secretsFilePath: fixture }); + expect(JSON.parse(resolved.secretsJson)).toEqual({ + fixtureSecret: "fixtureValue", + connection: { username: "fixtureUser", password: "fixturePassword" }, + }); + }); + + it("rejects supplying both secrets and secretsFilePath", () => { + expect(() => resolveLocalFileSecretStore({ secrets: { a: "b" }, secretsFilePath: "x.json" })).toThrow(/not both/); + }); + + it("rejects an invalid secrets file", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "dapr-secrets-")); + const badFile = path.join(dir, "bad.json"); + fs.writeFileSync(badFile, "{ not json", "utf8"); + try { + expect(() => resolveLocalFileSecretStore({ secretsFilePath: badFile })).toThrow(/Invalid secrets file/); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + }); + + describe("writeSecretsFile", () => { + it("writes the secrets JSON, creating directories as needed", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "dapr-secrets-")); + try { + const target = path.join(root, "nested", "dir"); + const written = writeSecretsFile(target, { alpha: "beta" }); + expect(written).toBe(path.join(target, DEFAULT_SECRETS_FILE_NAME)); + expect(JSON.parse(fs.readFileSync(written, "utf8"))).toEqual({ alpha: "beta" }); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + it("honors a custom file name and defaults the content", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "dapr-secrets-")); + try { + const written = writeSecretsFile(root, undefined, "custom.json"); + expect(path.basename(written)).toBe("custom.json"); + expect(JSON.parse(fs.readFileSync(written, "utf8"))).toEqual(DEFAULT_SECRETS); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + }); + + describe("DaprContainer.withSecretStore", () => { + it("registers the component and the secrets file", () => { + const dapr = new DaprContainer().withSecretStore(); + const stores = dapr.getSecretStores(); + expect(stores).toHaveLength(1); + expect(stores[0].name).toBe("localsecretstore"); + expect(dapr.getComponents().map((c) => c.type)).toContain("secretstores.local.file"); + }); + + it("supports multiple distinct secret stores", () => { + const dapr = new DaprContainer() + .withSecretStore({ secrets: { a: "1" } }) + .withSecretStore({ name: "second-store", secrets: { b: "2" } }); + expect(dapr.getSecretStores().map((s) => s.name)).toEqual(["localsecretstore", "second-store"]); + expect(dapr.getSecretStores().map((s) => s.containerSecretsFilePath)).toEqual([ + `${DEFAULT_SECRETS_CONTAINER_DIRECTORY}/secrets.json`, + `${DEFAULT_SECRETS_CONTAINER_DIRECTORY}/second-store.json`, + ]); + }); + + it("rejects duplicate component names", () => { + const dapr = new DaprContainer().withSecretStore(); + expect(() => dapr.withSecretStore()).toThrow(/already been registered/); + }); + + it("rejects conflicting secrets file paths", () => { + const dapr = new DaprContainer().withSecretStore({ containerSecretsFilePath: "/shared/secrets.json" }); + expect(() => dapr.withSecretStore({ name: "other", containerSecretsFilePath: "/shared/secrets.json" })).toThrow( + /already mapped/ + ); + }); + + it("returns a defensive copy of the registered stores", () => { + const dapr = new DaprContainer().withSecretStore(); + dapr.getSecretStores().pop(); + expect(dapr.getSecretStores()).toHaveLength(1); + }); + }); +}); diff --git a/src/SecretStore.ts b/src/SecretStore.ts new file mode 100644 index 0000000..bc1178b --- /dev/null +++ b/src/SecretStore.ts @@ -0,0 +1,200 @@ +/* +Copyright 2026 The Dapr Authors +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import fs from "node:fs"; +import path from "node:path"; +import { Component, MetadataEntry } from "./Component"; +import { DaprComponentNames } from "./Constants"; + +/** + * The directory inside the Dapr container where secret files are placed. + * Kept separate from the resources path so the runtime does not attempt to + * load the secrets file as a Dapr resource. + */ +export const DEFAULT_SECRETS_CONTAINER_DIRECTORY = "/dapr-secrets"; + +/** The default file name used for the local file secret store. */ +export const DEFAULT_SECRETS_FILE_NAME = "secrets.json"; + +/** The default separator used to flatten nested secrets. */ +export const DEFAULT_NESTED_SEPARATOR = ":"; + +/** + * The default set of secrets seeded into the local file secret store, mirroring + * the .NET SDK's `SecretStoreHarness`. + */ +export const DEFAULT_SECRETS: SecretsMap = { + secret1: "value1", + secret2: "value2", +}; + +/** + * A map of secrets. Values may either be a flat string or a nested map of + * key/value pairs (used for multi-valued secrets). + */ +export type SecretsMap = Record>; + +export type LocalFileSecretStoreOptions = { + /** Component name. Defaults to `localsecretstore`. */ + name?: string; + /** The secrets to seed into the secret store. Mutually exclusive with `secretsFilePath`. */ + secrets?: SecretsMap; + /** Path on the host to an existing JSON secrets file. Mutually exclusive with `secrets`. */ + secretsFilePath?: string; + /** Absolute path inside the container where the secrets file is written. */ + containerSecretsFilePath?: string; + /** Separator used when flattening nested secrets. Defaults to `:`. */ + nestedSeparator?: string; + /** When true, nested secrets are returned as multi-valued secrets rather than flattened. */ + multiValued?: boolean; +}; + +/** + * A fully resolved local file secret store, containing both the Dapr component + * definition and the JSON content that backs it. + */ +export type ResolvedLocalFileSecretStore = { + name: string; + component: Component; + secretsJson: string; + containerSecretsFilePath: string; +}; + +function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** + * Validates that a secrets map only contains strings or one level of nested strings. + * + * @param secrets The secrets map to validate. + * @throws If the map contains unsupported values. + */ +export function validateSecrets(secrets: SecretsMap): void { + if (!isPlainObject(secrets)) { + throw new Error("Secrets must be provided as an object of key/value pairs"); + } + for (const [key, value] of Object.entries(secrets)) { + if (typeof value === "string") { + continue; + } + if (!isPlainObject(value)) { + throw new Error(`Secret "${key}" must be a string or an object of string values`); + } + for (const [nestedKey, nestedValue] of Object.entries(value)) { + if (typeof nestedValue !== "string") { + throw new Error(`Secret "${key}.${nestedKey}" must be a string value`); + } + } + } +} + +/** + * Serializes a secrets map into the JSON document consumed by the + * `secretstores.local.file` component. + * + * @param secrets The secrets to serialize. Defaults to {@link DEFAULT_SECRETS}. + * @returns A JSON string. + */ +export function createSecretsJson(secrets: SecretsMap = DEFAULT_SECRETS): string { + validateSecrets(secrets); + return `${JSON.stringify(secrets, undefined, 2)}\n`; +} + +/** + * Builds a Dapr `secretstores.local.file` component. + * + * @param options Configuration options for the secret store component. + * @returns A new Component instance. + */ +export function createLocalFileSecretStoreComponent(options: LocalFileSecretStoreOptions = {}): Component { + const name = options.name ?? DaprComponentNames.SecretStoreComponentName; + if (!name.trim()) { + throw new Error("Secret store component name must not be empty"); + } + + const metadata: MetadataEntry[] = [ + { name: "secretsFile", value: resolveContainerSecretsFilePath(options) }, + { name: "nestedSeparator", value: options.nestedSeparator ?? DEFAULT_NESTED_SEPARATOR }, + ]; + + if (options.multiValued) { + metadata.push({ name: "multiValued", value: "true" }); + } + + return new Component(name, "secretstores.local.file", "v1", metadata); +} + +function resolveContainerSecretsFilePath(options: LocalFileSecretStoreOptions): string { + if (options.containerSecretsFilePath) { + return options.containerSecretsFilePath; + } + const name = options.name ?? DaprComponentNames.SecretStoreComponentName; + const fileName = name === DaprComponentNames.SecretStoreComponentName ? DEFAULT_SECRETS_FILE_NAME : `${name}.json`; + return `${DEFAULT_SECRETS_CONTAINER_DIRECTORY}/${fileName}`; +} + +/** + * Resolves the component definition and the backing JSON content for a local + * file secret store. + * + * @param options Configuration options for the secret store. + * @returns The resolved secret store. + */ +export function resolveLocalFileSecretStore(options: LocalFileSecretStoreOptions = {}): ResolvedLocalFileSecretStore { + if (options.secrets && options.secretsFilePath) { + throw new Error("Provide either `secrets` or `secretsFilePath`, not both"); + } + + let secretsJson: string; + if (options.secretsFilePath) { + const raw = fs.readFileSync(options.secretsFilePath, "utf8"); + try { + validateSecrets(JSON.parse(raw)); + } catch (error) { + throw new Error(`Invalid secrets file at ${options.secretsFilePath}: ${(error as Error).message}`); + } + secretsJson = raw; + } else { + secretsJson = createSecretsJson(options.secrets); + } + + const component = createLocalFileSecretStoreComponent(options); + + return { + name: component.name, + component, + secretsJson, + containerSecretsFilePath: resolveContainerSecretsFilePath(options), + }; +} + +/** + * Writes a secrets JSON file to disk, creating the containing directory when needed. + * Useful when tests want to mount an existing secrets file into the container. + * + * @param folderPath The directory to write the file into. + * @param secrets The secrets to write. Defaults to {@link DEFAULT_SECRETS}. + * @param fileName The file name. Defaults to `secrets.json`. + * @returns The full path of the written file. + */ +export function writeSecretsFile( + folderPath: string, + secrets: SecretsMap = DEFAULT_SECRETS, + fileName: string = DEFAULT_SECRETS_FILE_NAME +): string { + fs.mkdirSync(folderPath, { recursive: true }); + const fullPath = path.join(folderPath, fileName); + fs.writeFileSync(fullPath, createSecretsJson(secrets), "utf8"); + return fullPath; +} diff --git a/src/SecretStoreHarness.test.ts b/src/SecretStoreHarness.test.ts new file mode 100644 index 0000000..61decda --- /dev/null +++ b/src/SecretStoreHarness.test.ts @@ -0,0 +1,190 @@ +/* +Copyright 2026 The Dapr Authors +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { CommunicationProtocolEnum, DaprClient } from "@dapr/dapr"; +import path from "node:path"; +import { Network } from "testcontainers"; +import { DaprContainer } from "./DaprContainer"; +import { DEFAULT_SECRETS } from "./SecretStore"; +import { SecretStoreHarness } from "./SecretStoreHarness"; + +const FIXTURE_SECRETS_FILE = path.join(__dirname, "__fixtures__", "dapr-resources", "secrets.json"); + +describe("SecretStoreHarness", () => { + describe("configuration", () => { + it("configures a default secret store", () => { + const harness = new SecretStoreHarness(); + expect(harness.getSecretStoreName()).toBe("localsecretstore"); + const dapr = harness.getDaprContainer(); + expect(dapr.getAppName()).toBe("secretstore-app"); + expect(dapr.getSecretStores()).toHaveLength(1); + expect(JSON.parse(dapr.getSecretStores()[0].secretsJson)).toEqual(DEFAULT_SECRETS); + }); + + it("configures custom options", () => { + const harness = new SecretStoreHarness({ + appId: "custom-secrets-app", + appPort: 9100, + appChannelAddress: "host.testcontainers.internal", + daprLogLevel: "debug", + secretStoreName: "my-secrets", + secrets: { token: "abc123" }, + nestedSeparator: ".", + multiValued: true, + }); + + const dapr = harness.getDaprContainer(); + expect(harness.getSecretStoreName()).toBe("my-secrets"); + expect(dapr.getAppName()).toBe("custom-secrets-app"); + expect(dapr.getAppPort()).toBe(9100); + expect(dapr.getAppChannelAddress()).toBe("host.testcontainers.internal"); + + const [store] = dapr.getSecretStores(); + expect(JSON.parse(store.secretsJson)).toEqual({ token: "abc123" }); + const metadata = store.component.getMetadata(); + expect(metadata).toContainEqual({ name: "nestedSeparator", value: "." }); + expect(metadata).toContainEqual({ name: "multiValued", value: "true" }); + }); + + it("loads secrets from a file path", () => { + const harness = new SecretStoreHarness({ secretsFilePath: FIXTURE_SECRETS_FILE }); + const [store] = harness.getDaprContainer().getSecretStores(); + expect(JSON.parse(store.secretsJson).fixtureSecret).toBe("fixtureValue"); + }); + + it("throws when accessed before start", () => { + const harness = new SecretStoreHarness(); + expect(() => harness.getStartedDaprContainer()).toThrow(/has not been started/); + expect(() => harness.getHttpEndpoint()).toThrow(/has not been started/); + }); + + it("is safe to stop before start", async () => { + await expect(new SecretStoreHarness().stop()).resolves.toBeUndefined(); + }); + }); + + describe("end-to-end", () => { + it("retrieves secrets over HTTP via the harness", async () => { + await using network = await new Network().start(); + const harness = new SecretStoreHarness({ + appId: "secrets-e2e-app", + network, + secrets: { + secret1: "value1", + secret2: "value2", + connection: { username: "admin", password: "s3cr3t" }, + }, + }); + + try { + await harness.start(); + + expect(harness.getHttpEndpoint()).toMatch(/^http:\/\//); + expect(harness.getHttpPort()).toBeGreaterThan(0); + expect(harness.getGrpcPort()).toBeGreaterThan(0); + + await expect(harness.getSecret("secret1")).resolves.toEqual({ secret1: "value1" }); + await expect(harness.getSecretValue("secret2")).resolves.toBe("value2"); + + // Nested secrets are flattened using the default ":" separator. + await expect(harness.getSecretValue("connection:username")).resolves.toBe("admin"); + + const bulk = await harness.getBulkSecrets(); + expect(bulk["secret1"]).toEqual({ secret1: "value1" }); + expect(bulk["connection:password"]).toEqual({ "connection:password": "s3cr3t" }); + } finally { + await harness.stop(); + } + }, 300_000); + + it("retrieves secrets over gRPC", async () => { + await using network = await new Network().start(); + const harness = new SecretStoreHarness({ + appId: "secrets-grpc-app", + network, + secrets: { grpcSecret: "grpcValue" }, + }); + + try { + await harness.start(); + const client = harness.createDaprClient(CommunicationProtocolEnum.GRPC); + const secret = (await client.secret.get(harness.getSecretStoreName(), "grpcSecret")) as Record; + expect(secret["grpcSecret"]).toBe("grpcValue"); + } finally { + await harness.stop(); + } + }, 300_000); + + it("supports a custom nested separator", async () => { + await using network = await new Network().start(); + const harness = new SecretStoreHarness({ + appId: "secrets-separator-app", + network, + nestedSeparator: ".", + secrets: { db: { host: "localhost" } }, + }); + + try { + await harness.start(); + await expect(harness.getSecretValue("db.host")).resolves.toBe("localhost"); + } finally { + await harness.stop(); + } + }, 300_000); + + it("supports secrets loaded from a host file", async () => { + await using network = await new Network().start(); + const harness = new SecretStoreHarness({ + appId: "secrets-file-app", + network, + secretsFilePath: FIXTURE_SECRETS_FILE, + }); + + try { + await harness.start(); + await expect(harness.getSecretValue("fixtureSecret")).resolves.toBe("fixtureValue"); + await expect(harness.getSecretValue("connection:password")).resolves.toBe("fixturePassword"); + } finally { + await harness.stop(); + } + }, 300_000); + + it("supports multiple secret stores configured directly on DaprContainer", async () => { + await using network = await new Network().start(); + const dapr = new DaprContainer() + .withNetwork(network) + .withAppName("secrets-multi-app") + .withSecretStore({ secrets: { alpha: "one" } }) + .withSecretStore({ name: "second-store", secrets: { beta: "two" } }); + + await using started = await dapr.start(); + + const client = new DaprClient({ + daprHost: started.getHost(), + daprPort: started.getHttpPort().toString(), + }); + + try { + const alpha = (await client.secret.get("localsecretstore", "alpha")) as Record; + expect(alpha["alpha"]).toBe("one"); + + const beta = (await client.secret.get("second-store", "beta")) as Record; + expect(beta["beta"]).toBe("two"); + + await expect(client.secret.get("localsecretstore", "beta")).rejects.toBeDefined(); + } finally { + await client.stop(); + } + }, 300_000); + }); +}); diff --git a/src/SecretStoreHarness.ts b/src/SecretStoreHarness.ts new file mode 100644 index 0000000..9f88670 --- /dev/null +++ b/src/SecretStoreHarness.ts @@ -0,0 +1,199 @@ +/* +Copyright 2026 The Dapr Authors +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { CommunicationProtocolEnum, DaprClient } from "@dapr/dapr"; +import { Network, StartedNetwork } from "testcontainers"; +import { DaprContainer, StartedDaprContainer } from "./DaprContainer"; +import { LocalFileSecretStoreOptions, SecretsMap } from "./SecretStore"; + +export type SecretStoreHarnessOptions = { + appId?: string; + appPort?: number; + appChannelAddress?: string; + daprLogLevel?: string; + daprApiLoggingEnabled?: boolean; + daprRuntimeImage?: string; + /** Secret store component name. Defaults to `localsecretstore`. */ + secretStoreName?: string; + /** The secrets to seed. Mutually exclusive with `secretsFilePath`. */ + secrets?: SecretsMap; + /** Path on the host to an existing JSON secrets file. Mutually exclusive with `secrets`. */ + secretsFilePath?: string; + /** Separator used when flattening nested secrets. Defaults to `:`. */ + nestedSeparator?: string; + /** When true, nested secrets are returned as multi-valued secrets rather than flattened. */ + multiValued?: boolean; + network?: StartedNetwork; +}; + +/** + * Provides an implementation harness for Dapr's Secrets building block backed by + * the local file secret store, mirroring the SecretStoreHarness in the .NET SDK. + */ +export class SecretStoreHarness { + private network?: StartedNetwork; + private ownsNetwork = false; + private readonly daprContainer: DaprContainer; + private readonly secretStoreName: string; + private startedDaprContainer?: StartedDaprContainer; + private daprClient?: DaprClient; + + constructor(private readonly options: SecretStoreHarnessOptions = {}) { + const secretStoreOptions: LocalFileSecretStoreOptions = { + name: options.secretStoreName, + secrets: options.secrets, + secretsFilePath: options.secretsFilePath, + nestedSeparator: options.nestedSeparator, + multiValued: options.multiValued, + }; + + this.daprContainer = new DaprContainer(options.daprRuntimeImage) + .withAppName(options.appId ?? "secretstore-app") + .withDaprLogLevel(options.daprLogLevel ?? "info") + .withDaprApiLoggingEnabled(options.daprApiLoggingEnabled ?? false) + .withSecretStore(secretStoreOptions); + + this.secretStoreName = this.daprContainer.getSecretStores()[0].name; + + if (options.appPort) { + this.daprContainer.withAppPort(options.appPort); + } + if (options.appChannelAddress) { + this.daprContainer.withAppChannelAddress(options.appChannelAddress); + } + } + + public getDaprContainer(): DaprContainer { + return this.daprContainer; + } + + public getSecretStoreName(): string { + return this.secretStoreName; + } + + public async start(): Promise { + if (this.options.network) { + this.network = this.options.network; + this.ownsNetwork = false; + } else { + this.network = await new Network().start(); + this.ownsNetwork = true; + } + + this.daprContainer.withNetwork(this.network); + this.startedDaprContainer = await this.daprContainer.start(); + return this; + } + + public async stop(): Promise { + if (this.daprClient) { + try { + await this.daprClient.stop(); + } catch { + // Ignore errors during client shutdown + } + this.daprClient = undefined; + } + if (this.startedDaprContainer) { + await this.startedDaprContainer.stop(); + this.startedDaprContainer = undefined; + } + if (this.ownsNetwork && this.network) { + await this.network.stop(); + this.network = undefined; + } + } + + public getStartedDaprContainer(): StartedDaprContainer { + if (!this.startedDaprContainer) { + throw new Error("SecretStoreHarness has not been started. Call start() first."); + } + return this.startedDaprContainer; + } + + public getHost(): string { + return this.getStartedDaprContainer().getHost(); + } + + public getHttpPort(): number { + return this.getStartedDaprContainer().getHttpPort(); + } + + public getGrpcPort(): number { + return this.getStartedDaprContainer().getGrpcPort(); + } + + public getHttpEndpoint(): string { + return this.getStartedDaprContainer().getHttpEndpoint(); + } + + public getGrpcEndpoint(): string { + return this.getStartedDaprContainer().getGrpcEndpoint(); + } + + /** + * Creates (and caches) a DaprClient bound to the running sidecar. + * + * @param protocol The communication protocol to use. Defaults to HTTP. + */ + public createDaprClient(protocol: CommunicationProtocolEnum = CommunicationProtocolEnum.HTTP): DaprClient { + const started = this.getStartedDaprContainer(); + this.daprClient = new DaprClient({ + daprHost: started.getHost(), + daprPort: (protocol === CommunicationProtocolEnum.GRPC + ? started.getGrpcPort() + : started.getHttpPort() + ).toString(), + communicationProtocol: protocol, + }); + return this.daprClient; + } + + private getOrCreateClient(): DaprClient { + return this.daprClient ?? this.createDaprClient(); + } + + /** + * Retrieves a single secret from the local file secret store. + * + * @param key The secret key. + * @returns The secret as a key/value object. + */ + public async getSecret(key: string): Promise> { + const result = await this.getOrCreateClient().secret.get(this.secretStoreName, key); + return result as Record; + } + + /** + * Retrieves the value of a single secret from the local file secret store. + * + * @param key The secret key. + * @returns The secret value, or undefined if not present. + */ + public async getSecretValue(key: string): Promise { + const secret = await this.getSecret(key); + return secret?.[key]; + } + + /** + * Retrieves all secrets from the local file secret store. + */ + public async getBulkSecrets(): Promise>> { + const result = await this.getOrCreateClient().secret.getBulk(this.secretStoreName); + return result as Record>; + } + + public async [Symbol.asyncDispose](): Promise { + await this.stop(); + } +} diff --git a/src/__fixtures__/dapr-resources/secrets.json b/src/__fixtures__/dapr-resources/secrets.json new file mode 100644 index 0000000..c233ed7 --- /dev/null +++ b/src/__fixtures__/dapr-resources/secrets.json @@ -0,0 +1,7 @@ +{ + "fixtureSecret": "fixtureValue", + "connection": { + "username": "fixtureUser", + "password": "fixturePassword" + } +} diff --git a/src/index.ts b/src/index.ts index bc2ab28..f3435a0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -21,5 +21,7 @@ export * from "./DaprSchedulerContainer"; export * from "./HttpEndpoint"; export * from "./MetadataEntry"; export * from "./RedisContainer"; +export * from "./SecretStore"; +export * from "./SecretStoreHarness"; export * from "./Subscription"; export * from "./WorkflowHarness"; From 48fbf2096f0050e0cf146bf7d0a6b03afcf69066 Mon Sep 17 00:00:00 2001 From: Whit Waldo Date: Mon, 14 Sep 2026 11:30:00 -0500 Subject: [PATCH 2/2] Fixing another possible race condition Signed-off-by: Whit Waldo --- src/DaprContainer.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/DaprContainer.ts b/src/DaprContainer.ts index d2d4c00..98ea731 100644 --- a/src/DaprContainer.ts +++ b/src/DaprContainer.ts @@ -699,6 +699,11 @@ export class StartedDaprContainer extends AbstractStartedContainer { return stoppedTestContainer; } + getHost(): string { + const host = super.getHost(); + return host === "localhost" ? "127.0.0.1" : host; + } + getHttpPort(): number { return this.getMappedPort(DAPRD_DEFAULT_HTTP_PORT); }