diff --git a/AGENTS.md b/AGENTS.md index 8ecbccbc4..8e64fe5df 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,6 +12,7 @@ It captures practical rules that prevent avoidable CI and PR churn. - If new learnings or misunderstandings are discovered, propose an `AGENTS.md` update in the same PR. - Tests should verify observable behavior changes, not only internal/config state. - Example: for a security option, assert a real secure/insecure behavior difference. +- Test-only helper files under `src` (for example `*-test-utils.ts`) must be explicitly excluded from package `tsconfig.build.json` so they are not emitted into `build` and accidentally published. - Vitest runs tests concurrently by default (`sequence.concurrent: true` in `vitest.config.ts`). - Tests that rely on shared/global mocks (for example `vi.spyOn` on shared loggers/singletons) can be flaky due to interleaving or automatic mock resets. - Prefer asserting observable behavior instead of shared global mock state when possible. diff --git a/docs/modules/azurite.md b/docs/modules/azurite.md index b9d69fdbd..13fcf1686 100644 --- a/docs/modules/azurite.md +++ b/docs/modules/azurite.md @@ -59,3 +59,17 @@ Choose an image from the [container registry](https://hub.docker.com/r/microsoft [](../../packages/modules/azurite/src/azurite-container.test.ts) inside_block:customPorts + +### With HTTPS (PEM certificate) + + +[Code](../../packages/modules/azurite/src/azurite-container.test.ts) inside_block:httpsWithPem +[`azurite-test-utils`](../../packages/modules/azurite/src/azurite-test-utils.ts) inside_block:azuriteTestUtils + + +### With OAuth (basic) + + +[Code](../../packages/modules/azurite/src/azurite-container.test.ts) inside_block:withOAuth +[`azurite-test-utils`](../../packages/modules/azurite/src/azurite-test-utils.ts) inside_block:azuriteTestUtils + diff --git a/package-lock.json b/package-lock.json index f6fedcf17..0d8249958 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19108,8 +19108,8 @@ }, "devDependencies": { "@azure/data-tables": "^13.3.2", - "@azure/storage-blob": "12.29.1", - "@azure/storage-queue": "12.28.1" + "@azure/storage-blob": "^12.29.1", + "@azure/storage-queue": "^12.28.1" } }, "packages/modules/azurite/node_modules/@azure/abort-controller": { diff --git a/packages/modules/azurite/src/azurite-container.test.ts b/packages/modules/azurite/src/azurite-container.test.ts index 4e53e3f89..69eb06695 100644 --- a/packages/modules/azurite/src/azurite-container.test.ts +++ b/packages/modules/azurite/src/azurite-container.test.ts @@ -1,10 +1,15 @@ import { TableClient, TableEntity } from "@azure/data-tables"; import { BlobServiceClient, StorageSharedKeyCredential } from "@azure/storage-blob"; import { QueueServiceClient } from "@azure/storage-queue"; +import fs from "node:fs"; +import path from "node:path"; import { getImage } from "../../../testcontainers/src/utils/test-helper"; import { AzuriteContainer } from "./azurite-container"; +import { createOAuthToken, createTokenCredential, getTlsPipelineOptions } from "./azurite-test-utils"; const IMAGE = getImage(__dirname); +const TEST_CERT = fs.readFileSync(path.resolve(__dirname, "..", "test-certs", "azurite-test-cert.pem"), "utf8"); +const TEST_KEY = fs.readFileSync(path.resolve(__dirname, "..", "test-certs", "azurite-test-key.pem"), "utf8"); describe("AzuriteContainer", { timeout: 240_000 }, () => { it("should upload and download blob with default credentials", async () => { @@ -117,6 +122,54 @@ describe("AzuriteContainer", { timeout: 240_000 }, () => { await containerClient.createIfNotExists(); }); + it("should be able to enable HTTPS with PEM certificate and key", async () => { + // httpsWithPem { + await using container = await new AzuriteContainer(IMAGE).withSsl(TEST_CERT, TEST_KEY).start(); + + const connectionString = container.getConnectionString(); + expect(connectionString).toContain("DefaultEndpointsProtocol=https"); + expect(connectionString).toContain("BlobEndpoint=https://"); + expect(connectionString).toContain("QueueEndpoint=https://"); + expect(connectionString).toContain("TableEndpoint=https://"); + + const serviceClient = BlobServiceClient.fromConnectionString(connectionString, getTlsPipelineOptions(TEST_CERT)); + const containerClient = serviceClient.getContainerClient("test"); + await containerClient.createIfNotExists(); + + const containerItem = await serviceClient.listContainers().next(); + expect(containerItem.value?.name).toBe("test"); + // } + }); + + it("should be able to enable OAuth basic authentication", async () => { + // withOAuth { + await using container = await new AzuriteContainer(IMAGE).withSsl(TEST_CERT, TEST_KEY).withOAuth().start(); + + const validServiceClient = new BlobServiceClient( + container.getBlobEndpoint(), + createTokenCredential(createOAuthToken("https://storage.azure.com")), + getTlsPipelineOptions(TEST_CERT) + ); + const validContainerClient = validServiceClient.getContainerClient(`oauth-valid-${Date.now()}`); + await validContainerClient.create(); + await validContainerClient.delete(); + + const invalidServiceClient = new BlobServiceClient( + container.getBlobEndpoint(), + createTokenCredential(createOAuthToken("https://invalidaccount.blob.core.windows.net")), + getTlsPipelineOptions(TEST_CERT) + ); + const invalidContainerClient = invalidServiceClient.getContainerClient(`oauth-invalid-${Date.now()}`); + await expect(invalidContainerClient.create()).rejects.toThrow("Server failed to authenticate the request"); + // } + }); + + it("should require HTTPS when enabling OAuth", async () => { + await expect(new AzuriteContainer(IMAGE).withOAuth().start()).rejects.toThrow( + "OAuth requires HTTPS endpoint. Configure SSL first with withSsl() or withSslPfx()." + ); + }); + it("should be able to use in-memory persistence", async () => { // inMemoryPersistence { await using container = await new AzuriteContainer(IMAGE).withInMemoryPersistence().start(); diff --git a/packages/modules/azurite/src/azurite-container.ts b/packages/modules/azurite/src/azurite-container.ts index 0221b9eaa..0caf46183 100755 --- a/packages/modules/azurite/src/azurite-container.ts +++ b/packages/modules/azurite/src/azurite-container.ts @@ -13,6 +13,11 @@ const QUEUE_PORT = 10001; const TABLE_PORT = 10002; const DEFAULT_ACCOUNT_NAME = "devstoreaccount1"; const DEFAULT_ACCOUNT_KEY = "Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw=="; +const PEM_CERT_PATH = "/azurite-cert.pem"; +const PEM_KEY_PATH = "/azurite-key.pem"; +const PFX_CERT_PATH = "/azurite-cert.pfx"; + +type Protocol = "http" | "https"; export class AzuriteContainer extends GenericContainer { constructor(image: string) { @@ -38,6 +43,11 @@ export class AzuriteContainer extends GenericContainer { private skipApiVersionCheck = false; private inMemoryPersistence = false; private extentMemoryLimitInMegaBytes?: number = undefined; + private cert?: string = undefined; + private certPath?: string = undefined; + private key?: string = undefined; + private password?: string = undefined; + private oauthEnabled = false; /** * Sets a custom storage account name (default account will be disabled). @@ -112,9 +122,49 @@ export class AzuriteContainer extends GenericContainer { return this; } + /** + * Configure SSL with a custom certificate and private key. + * + * @param cert The PEM certificate file content. + * @param key The PEM key file content. + */ + public withSsl(cert: string, key: string): this { + this.cert = cert; + this.key = key; + this.password = undefined; + this.certPath = PEM_CERT_PATH; + return this; + } + + /** + * Configure SSL with a custom certificate and password. + * + * @param cert The PFX certificate file content. + * @param password The password securing the certificate. + */ + public withSslPfx(cert: string, password: string): this { + this.cert = cert; + this.key = undefined; + this.password = password; + this.certPath = PFX_CERT_PATH; + return this; + } + + /** + * Enable OAuth authentication with basic mode. + */ + public withOAuth(): this { + this.oauthEnabled = true; + return this; + } + public override async start(): Promise { const command = ["--blobHost", "0.0.0.0", "--queueHost", "0.0.0.0", "--tableHost", "0.0.0.0"]; + if (this.oauthEnabled && this.cert === undefined) { + throw new Error("OAuth requires HTTPS endpoint. Configure SSL first with withSsl() or withSslPfx()."); + } + if (this.inMemoryPersistence) { command.push("--inMemoryPersistence"); @@ -127,6 +177,37 @@ export class AzuriteContainer extends GenericContainer { command.push("--skipApiVersionCheck"); } + if (this.cert !== undefined && this.certPath !== undefined) { + const contentsToCopy = [ + { + content: this.cert, + target: this.certPath, + mode: 0o644, + }, + ]; + + if (this.key) { + contentsToCopy.push({ + content: this.key, + target: PEM_KEY_PATH, + mode: 0o600, + }); + } + + this.withCopyContentToContainer(contentsToCopy); + command.push("--cert", this.certPath); + + if (this.key) { + command.push("--key", PEM_KEY_PATH); + } else if (this.password !== undefined) { + command.push("--pwd", this.password); + } + } + + if (this.oauthEnabled) { + command.push("--oauth", "basic"); + } + this.withCommand(command).withExposedPorts(this.blobPort, this.queuePort, this.tablePort); if (this.accountName !== DEFAULT_ACCOUNT_NAME || this.accountKey !== DEFAULT_ACCOUNT_KEY) { @@ -135,6 +216,7 @@ export class AzuriteContainer extends GenericContainer { }); } + const protocol: Protocol = this.cert === undefined ? "http" : "https"; const startedContainer = await super.start(); return new StartedAzuriteContainer( @@ -143,7 +225,8 @@ export class AzuriteContainer extends GenericContainer { this.accountKey, this.blobPort, this.queuePort, - this.tablePort + this.tablePort, + protocol ); } } @@ -155,7 +238,8 @@ export class StartedAzuriteContainer extends AbstractStartedContainer { private readonly accountKey: string, private readonly blobPort: PortWithOptionalBinding, private readonly queuePort: PortWithOptionalBinding, - private readonly tablePort: PortWithOptionalBinding + private readonly tablePort: PortWithOptionalBinding, + private readonly protocol: Protocol ) { super(startedTestContainer); } @@ -211,13 +295,13 @@ export class StartedAzuriteContainer extends AbstractStartedContainer { * @returns A connection string in the form of `DefaultEndpointsProtocol=[protocol];AccountName=[accountName];AccountKey=[accountKey];BlobEndpoint=[blobEndpoint];QueueEndpoint=[queueEndpoint];TableEndpoint=[tableEndpoint];` */ public getConnectionString(): string { - return `DefaultEndpointsProtocol=http;AccountName=${this.accountName};AccountKey=${ + return `DefaultEndpointsProtocol=${this.protocol};AccountName=${this.accountName};AccountKey=${ this.accountKey };BlobEndpoint=${this.getBlobEndpoint()};QueueEndpoint=${this.getQueueEndpoint()};TableEndpoint=${this.getTableEndpoint()};`; } private getEndpoint(port: number, containerName: string): string { - const url = new URL(`http://${this.getHost()}`); + const url = new URL(`${this.protocol}://${this.getHost()}`); url.port = port.toString(); url.pathname = containerName; return url.toString(); diff --git a/packages/modules/azurite/src/azurite-test-utils.ts b/packages/modules/azurite/src/azurite-test-utils.ts new file mode 100644 index 000000000..3a079b173 --- /dev/null +++ b/packages/modules/azurite/src/azurite-test-utils.ts @@ -0,0 +1,42 @@ +import type { TokenCredential } from "@azure/core-auth"; +import { BlobServiceClient } from "@azure/storage-blob"; + +// azuriteTestUtils { +type BlobClientPipelineOptions = NonNullable[1]>; +type BlobClientPipelineOptionsWithTls = BlobClientPipelineOptions & { tlsOptions: { ca: string } }; + +const base64UrlEncode = (value: string): string => Buffer.from(value).toString("base64url"); + +const createJwtToken = (payload: Record): string => { + const header = { alg: "none", typ: "JWT" }; + return `${base64UrlEncode(JSON.stringify(header))}.${base64UrlEncode(JSON.stringify(payload))}.`; +}; + +export const getTlsPipelineOptions = (ca: string): BlobClientPipelineOptionsWithTls => ({ + tlsOptions: { + ca, + }, +}); + +export const createOAuthToken = (audience: string): string => { + const now = Math.floor(Date.now() / 1000); + + return createJwtToken({ + nbf: now - 60, + iat: now - 60, + exp: now + 3600, + iss: "https://sts.windows.net/ab1f708d-50f6-404c-a006-d71b2ac7a606/", + aud: audience, + scp: "user_impersonation", + oid: "23657296-5cd5-45b0-a809-d972a7f4dfe1", + tid: "dd0d0df1-06c3-436c-8034-4b9a153097ce", + }); +}; + +export const createTokenCredential = (token: string): TokenCredential => ({ + getToken: async () => ({ + token, + expiresOnTimestamp: Date.now() + 3600_000, + }), +}); +// } diff --git a/packages/modules/azurite/test-certs/azurite-test-cert.pem b/packages/modules/azurite/test-certs/azurite-test-cert.pem new file mode 100644 index 000000000..5e32556ae --- /dev/null +++ b/packages/modules/azurite/test-certs/azurite-test-cert.pem @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDJzCCAg+gAwIBAgIUS1fWkcVQ6GsvbPeidtOwMG5GR3QwDQYJKoZIhvcNAQEL +BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MCAXDTI2MDIxNzA5MTk0NFoYDzMwMjUw +NjIwMDkxOTQ0WjAUMRIwEAYDVQQDDAlsb2NhbGhvc3QwggEiMA0GCSqGSIb3DQEB +AQUAA4IBDwAwggEKAoIBAQCdInerSj0HMJ1Wi8VCWhmur547vY5txq4V8x+FZb0Y +HezN9m7nUXLJTf3km2Ja3rzo9vzf2wz+YBblcMDlLb7JAie3IW7N8Vvdjjf0cBYy +XPHNlXbG2qt59pW9N3vCUsV8JeJkirQ9xgNAD+bbQYT4Rq+qCwfiSvlJVMQtvS1o +yE7nqYqg5wtbxvNhRLmZM95OlVJkTj9Pa5zsAw5/OoyLrWr2S/2ZRgC/UnilksYZ +zPJnN6Y1dESQeAQmYe2qMh6IVivmvqo0uT4UH/BSoSqn4adAg2U3i4i20rqJE0nC +/LbmSEmbkTSjvEArp2HMgJeF8mk9hbweXD2DbAWOCRiHAgMBAAGjbzBtMB0GA1Ud +DgQWBBReV/WTwymDnGnTvuEmao6dYq/hezAfBgNVHSMEGDAWgBReV/WTwymDnGnT +vuEmao6dYq/hezAPBgNVHRMBAf8EBTADAQH/MBoGA1UdEQQTMBGCCWxvY2FsaG9z +dIcEfwAAATANBgkqhkiG9w0BAQsFAAOCAQEAMEJ6TZ7sNnY4uMFIHosaq7EOD0kf +k+oU1mPxpla626L02HMbcllZf0I6Dp3T0CGTkrw7DBqnf1ONHBvhradntU4GHxup +p4qYFC5/qoe+OrbSWYxTeWxmnC2NB+ssR6MpQ/CyvufmgdcwiiSUWMAz4FbYhVTM +58nkdOmKTXGzKIfP6PVIpVLLwVRKRO/nwjoH8PB4XlX7S6aGGkR4yLPEAtj8cjfv +ki+n+vHhqunALzjtDDERUBDNIsbnzS1LUP9Sj2prycPvMTA0vb6cNfJ+Rof2Dfl0 +Q7dqgYKMTcM9BL0YTo/GNNFNTZ4IVa3fOW23AwQvyFrWjSLp9e9a0ECjyA== +-----END CERTIFICATE----- diff --git a/packages/modules/azurite/test-certs/azurite-test-key.pem b/packages/modules/azurite/test-certs/azurite-test-key.pem new file mode 100644 index 000000000..52209908e --- /dev/null +++ b/packages/modules/azurite/test-certs/azurite-test-key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCdInerSj0HMJ1W +i8VCWhmur547vY5txq4V8x+FZb0YHezN9m7nUXLJTf3km2Ja3rzo9vzf2wz+YBbl +cMDlLb7JAie3IW7N8Vvdjjf0cBYyXPHNlXbG2qt59pW9N3vCUsV8JeJkirQ9xgNA +D+bbQYT4Rq+qCwfiSvlJVMQtvS1oyE7nqYqg5wtbxvNhRLmZM95OlVJkTj9Pa5zs +Aw5/OoyLrWr2S/2ZRgC/UnilksYZzPJnN6Y1dESQeAQmYe2qMh6IVivmvqo0uT4U +H/BSoSqn4adAg2U3i4i20rqJE0nC/LbmSEmbkTSjvEArp2HMgJeF8mk9hbweXD2D +bAWOCRiHAgMBAAECggEACWhwuXONRNrEc36UgGesRaO5JG2HKLZuYjJiGZa2TsqC +jPBWF95uB3R2dj4BZ7G8phwc7CWNg8YVXjEKiOOHb+SDo+N0uBAyhl4x+8jMOMFi +Lti7OaZfrEKMbj2eA3aji/kpXV4FSEo3Ppoqdx0sohV6sK0kEIiZn/+FwWakCzO+ +9Mt2q4+2X+YUMZ1hGlqrvWaf6k5wJGj/3njMNY/W2hTDeB4n8126UtJukCfA/KRc +IGnnGBakGnWNn9ov2NRqHoZtvp7FMI4vVRI1OSc0UBA/HEEey/A2fGenv7MkekFV +m9IP+tJ4cnmc+kBHExgi8Fbxv3gqtAT/HRJevQpwCQKBgQDVDYEccAaEyBqp/KhK +iRkw6INShkSfgje/xO+2I+88r+Op5vN03Edwvicagt5gpWd4QwXG/ep9+KqQN2+9 +SDRS7aXt1cgyd8O4NG8y4KTD/fjSO4IyRshAJVwoXTGSEcxj2+KBl9+SOa++h9eq +tZaNj2YmE4UgtnwTS5n8O+8p2QKBgQC8z1U7gcNxUoVMYaufADMoqJUz8jI4G9qV +Up0rKenYdZxwqBHnpIRRk0YXiaWyVrMLKD+xIntxSwCLA3ohT4saR5EMxcJXNFbG +IbO+Y7PdWsnXbvcEkjRPUvDhIoxbr3YTyfhyvUdnAPjfHobDjyRdB2uwmlKVab5n +eePnR+l5XwKBgEheJu3262/s3InDBZMT0Je5UuoUK3kW7ULZbScsO5YclLNgfG/E +ZwvXu0aZD1o6tNO3yF2YYC9b6OvFuNHNleBZUtRfmnnyDmwie2cHwU/Fk+AtUIMt +YdXQGuanCTB5lTiSNvUYFlv/9j88uzgEKFh7ThI+7Sh4c9rGAk8YOJu5AoGBAKON +biWH+Ib3lqRdjs7C244Cyowe5sWXyzbCQ4caXYi2CHfF/wyLhFstme/VuoTLeXjW +uqV0Wz3+XFAPCQJF5xcym0FXJUto+SnUE+F+eFXsyR8m7i81fr6f+CztQmxBh6UI +tYCe2XUucGbGCLLqEfPL88sdQyBOYzM7cOHtdx89AoGADq/aFYbOGAIElIn3RGDp +M8OjqYQiV42K50LVkKtSJ93QI5T+TvV11GFRjpVJt270UXbTqZRdrwMwKCoVGcrR +IYZjJgpKlJ2eyh//GmJ91zNf5pM53599C6BEUTlAoeuShWQ3ogQ1o1n4WLT4OHWI +aFZQktSyi0gU5Mku9BBu+OY= +-----END PRIVATE KEY----- diff --git a/packages/modules/azurite/tsconfig.build.json b/packages/modules/azurite/tsconfig.build.json index ff7390b10..fe8ec452c 100644 --- a/packages/modules/azurite/tsconfig.build.json +++ b/packages/modules/azurite/tsconfig.build.json @@ -2,11 +2,12 @@ "extends": "./tsconfig.json", "exclude": [ "build", - "src/**/*.test.ts" + "src/**/*.test.ts", + "src/azurite-test-utils.ts" ], "references": [ { "path": "../../testcontainers" } ] -} \ No newline at end of file +}