Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
14 changes: 14 additions & 0 deletions docs/modules/azurite.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,3 +59,17 @@ Choose an image from the [container registry](https://hub.docker.com/r/microsoft
<!--codeinclude-->
[](../../packages/modules/azurite/src/azurite-container.test.ts) inside_block:customPorts
<!--/codeinclude-->

### With HTTPS (PEM certificate)

<!--codeinclude-->
[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
<!--/codeinclude-->

### With OAuth (basic)

<!--codeinclude-->
[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
<!--/codeinclude-->
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

53 changes: 53 additions & 0 deletions packages/modules/azurite/src/azurite-container.test.ts
Original file line number Diff line number Diff line change
@@ -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 () => {
Expand Down Expand Up @@ -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();
Expand Down
92 changes: 88 additions & 4 deletions packages/modules/azurite/src/azurite-container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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).
Expand Down Expand Up @@ -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<StartedAzuriteContainer> {
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");

Expand All @@ -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) {
Expand All @@ -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(
Expand All @@ -143,7 +225,8 @@ export class AzuriteContainer extends GenericContainer {
this.accountKey,
this.blobPort,
this.queuePort,
this.tablePort
this.tablePort,
protocol
);
}
}
Expand All @@ -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);
}
Expand Down Expand Up @@ -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();
Expand Down
42 changes: 42 additions & 0 deletions packages/modules/azurite/src/azurite-test-utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import type { TokenCredential } from "@azure/core-auth";
import { BlobServiceClient } from "@azure/storage-blob";

// azuriteTestUtils {
type BlobClientPipelineOptions = NonNullable<Parameters<typeof BlobServiceClient.fromConnectionString>[1]>;
type BlobClientPipelineOptionsWithTls = BlobClientPipelineOptions & { tlsOptions: { ca: string } };

const base64UrlEncode = (value: string): string => Buffer.from(value).toString("base64url");

const createJwtToken = (payload: Record<string, string | number>): 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,
}),
});
// }
19 changes: 19 additions & 0 deletions packages/modules/azurite/test-certs/azurite-test-cert.pem
Original file line number Diff line number Diff line change
@@ -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-----
28 changes: 28 additions & 0 deletions packages/modules/azurite/test-certs/azurite-test-key.pem
Original file line number Diff line number Diff line change
@@ -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-----
5 changes: 3 additions & 2 deletions packages/modules/azurite/tsconfig.build.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,12 @@
"extends": "./tsconfig.json",
"exclude": [
"build",
"src/**/*.test.ts"
"src/**/*.test.ts",
"src/azurite-test-utils.ts"
],
"references": [
{
"path": "../../testcontainers"
}
]
}
}
Loading