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
9 changes: 9 additions & 0 deletions docs/modules/postgresql.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,15 @@ Choose an image from the [container registry](https://hub.docker.com/_/postgres)
[](../../packages/modules/postgresql/src/postgresql-container.test.ts) inside_block:pgSetUsername
<!--/codeinclude-->

### With SSL

!!! note
`withSSL()` / `withSSLCert()` expect certificate/key files that already exist. They do not generate key material.

<!--codeinclude-->
[](../../packages/modules/postgresql/src/postgresql-container.test.ts) inside_block:pgSslConnect
<!--/codeinclude-->

### Snapshots

!!! warning
Expand Down
24 changes: 24 additions & 0 deletions packages/modules/postgresql/src/pgvector-container.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import path from "node:path";
import { Client } from "pg";
import { getImage } from "../../../testcontainers/src/utils/test-helper";
import { PostgreSqlContainer } from "./postgresql-container";

const IMAGE = getImage(__dirname);
const SSL_SERVER_CERT = path.resolve(__dirname, "test-certs/server.crt");
const SSL_SERVER_KEY = path.resolve(__dirname, "test-certs/server.key");

describe("PgvectorContainer", { timeout: 180_000 }, () => {
it("should work", async () => {
Expand Down Expand Up @@ -41,4 +44,25 @@ describe("PgvectorContainer", { timeout: 180_000 }, () => {

await client.end();
});

it("should connect with SSL", async () => {
await using container = await new PostgreSqlContainer(IMAGE).withSSL(SSL_SERVER_CERT, SSL_SERVER_KEY).start();

const client = new Client({
host: container.getHost(),
port: container.getPort(),
database: container.getDatabase(),
user: container.getUsername(),
password: container.getPassword(),
ssl: {
rejectUnauthorized: false,
},
});
await client.connect();

const result = await client.query("SELECT ssl FROM pg_stat_ssl WHERE pid = pg_backend_pid()");
expect(result.rows[0]).toEqual({ ssl: true });

await client.end();
});
});
24 changes: 24 additions & 0 deletions packages/modules/postgresql/src/postgis-container.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import path from "node:path";
import { Client } from "pg";
import { getImage } from "../../../testcontainers/src/utils/test-helper";
import { PostgreSqlContainer } from "./postgresql-container";

const IMAGE = getImage(__dirname);
const SSL_SERVER_CERT = path.resolve(__dirname, "test-certs/server.crt");
const SSL_SERVER_KEY = path.resolve(__dirname, "test-certs/server.key");

describe("PostgisContainer", { timeout: 180_000 }, () => {
it("should work", async () => {
Expand Down Expand Up @@ -41,4 +44,25 @@ describe("PostgisContainer", { timeout: 180_000 }, () => {

await client.end();
});

it("should connect with SSL", async () => {
await using container = await new PostgreSqlContainer(IMAGE).withSSL(SSL_SERVER_CERT, SSL_SERVER_KEY).start();

const client = new Client({
host: container.getHost(),
port: container.getPort(),
database: container.getDatabase(),
user: container.getUsername(),
password: container.getPassword(),
ssl: {
rejectUnauthorized: false,
},
});
await client.connect();

const result = await client.query("SELECT ssl FROM pg_stat_ssl WHERE pid = pg_backend_pid()");
expect(result.rows[0]).toEqual({ ssl: true });

await client.end();
});
});
39 changes: 39 additions & 0 deletions packages/modules/postgresql/src/postgresql-container.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
import path from "node:path";
import { Client } from "pg";
import { getImage } from "../../../testcontainers/src/utils/test-helper";
import { PostgreSqlContainer } from "./postgresql-container";

const IMAGE = getImage(__dirname);
const SSL_CA_CERT = path.resolve(__dirname, "test-certs/ca.crt");
const SSL_SERVER_CERT = path.resolve(__dirname, "test-certs/server.crt");
const SSL_SERVER_KEY = path.resolve(__dirname, "test-certs/server.key");

describe("PostgreSqlContainer", { timeout: 180_000 }, () => {
it("should connect and return a query result", async () => {
Expand Down Expand Up @@ -110,4 +114,39 @@ describe("PostgreSqlContainer", { timeout: 180_000 }, () => {

await expect(() => container.start()).rejects.toThrow();
});

it("should connect with SSL", async () => {
// pgSslConnect {
await using container = await new PostgreSqlContainer(IMAGE)
.withSSLCert(SSL_CA_CERT, SSL_SERVER_CERT, SSL_SERVER_KEY)
.start();

const client = new Client({
host: container.getHost(),
port: container.getPort(),
database: container.getDatabase(),
user: container.getUsername(),
password: container.getPassword(),
ssl: {
rejectUnauthorized: false,
},
});
await client.connect();

const result = await client.query("SELECT ssl FROM pg_stat_ssl WHERE pid = pg_backend_pid()");
expect(result.rows[0]).toEqual({ ssl: true });

await client.end();
// }
});

it("should validate SSL certificate paths", () => {
const container = new PostgreSqlContainer(IMAGE);

expect(() => container.withSSL("", "server.key")).toThrow("SSL certificate file should not be empty.");
expect(() => container.withSSL("server.crt", "")).toThrow("SSL key file should not be empty.");
expect(() => container.withSSLCert("", "server.crt", "server.key")).toThrow(
"SSL CA certificate file should not be empty."
);
});
});
81 changes: 81 additions & 0 deletions packages/modules/postgresql/src/postgresql-container.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,41 @@
import { AbstractStartedContainer, GenericContainer, StartedTestContainer, Wait } from "testcontainers";

const POSTGRES_PORT = 5432;
const POSTGRES_SSL_PATH = "/tmp/testcontainers-node/postgres";
const POSTGRES_SSL_CA_CERT_PATH = `${POSTGRES_SSL_PATH}/ca_cert.pem`;
const POSTGRES_SSL_CERT_PATH = `${POSTGRES_SSL_PATH}/server.crt`;
const POSTGRES_SSL_KEY_PATH = `${POSTGRES_SSL_PATH}/server.key`;
const POSTGRES_SSL_ENTRYPOINT_PATH = "/usr/local/bin/docker-entrypoint-ssl.sh";
const POSTGRES_SSL_ENTRYPOINT_CONTENT = `#!/bin/sh
set -eu

puid="$(id -u postgres)"
pgid="$(id -g postgres)"

if [ -z "$puid" ] || [ -z "$pgid" ]; then
echo "Unable to determine postgres uid/gid for SSL key material ownership"
exit 1
fi

for file in "${POSTGRES_SSL_CA_CERT_PATH}" "${POSTGRES_SSL_CERT_PATH}" "${POSTGRES_SSL_KEY_PATH}"; do
if [ -f "$file" ]; then
chown "$puid:$pgid" "$file"
chmod 600 "$file"
fi
done

exec /usr/local/bin/docker-entrypoint.sh "$@"
`;

type PostgreSqlSslConfig = {
caCertPath?: string;
};

export class PostgreSqlContainer extends GenericContainer {
private database = "test";
private username = "test";
private password = "test";
private sslConfig?: PostgreSqlSslConfig;

constructor(image: string) {
super(image);
Expand All @@ -29,12 +59,63 @@ export class PostgreSqlContainer extends GenericContainer {
return this;
}

public withSSL(certFile: string, keyFile: string, caCertFile?: string): this {
if (!certFile) throw new Error("SSL certificate file should not be empty.");
if (!keyFile) throw new Error("SSL key file should not be empty.");
if (caCertFile !== undefined && !caCertFile) throw new Error("SSL CA certificate file should not be empty.");

const filesToCopy = [
{ source: certFile, target: POSTGRES_SSL_CERT_PATH, mode: 0o600 },
{ source: keyFile, target: POSTGRES_SSL_KEY_PATH, mode: 0o600 },
];

if (caCertFile) {
filesToCopy.push({ source: caCertFile, target: POSTGRES_SSL_CA_CERT_PATH, mode: 0o600 });
}

this.sslConfig = {
caCertPath: caCertFile ? POSTGRES_SSL_CA_CERT_PATH : undefined,
};

this.withCopyFilesToContainer(filesToCopy)
.withCopyContentToContainer([
{
content: POSTGRES_SSL_ENTRYPOINT_CONTENT,
target: POSTGRES_SSL_ENTRYPOINT_PATH,
mode: 0o700,
},
])
.withEntrypoint(["sh", POSTGRES_SSL_ENTRYPOINT_PATH]);

return this;
}

public withSSLCert(caCertFile: string, certFile: string, keyFile: string): this {
if (!caCertFile) throw new Error("SSL CA certificate file should not be empty.");
return this.withSSL(certFile, keyFile, caCertFile);
}

public override async start(): Promise<StartedPostgreSqlContainer> {
this.withEnvironment({
POSTGRES_DB: this.database,
POSTGRES_USER: this.username,
POSTGRES_PASSWORD: this.password,
});
if (this.sslConfig) {
const command = this.createOpts.Cmd;
if (!command || command[0] === "postgres") {
this.withCommand([
...(command ?? ["postgres"]),
"-c",
"ssl=on",
"-c",
`ssl_cert_file=${POSTGRES_SSL_CERT_PATH}`,
"-c",
`ssl_key_file=${POSTGRES_SSL_KEY_PATH}`,
...(this.sslConfig.caCertPath ? ["-c", `ssl_ca_file=${this.sslConfig.caCertPath}`] : []),
]);
}
}
if (!this.healthCheck) {
this.withHealthCheck({
test: [
Expand Down
22 changes: 22 additions & 0 deletions packages/modules/postgresql/src/test-certs/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Test certificates

This directory contains test-only certificates for PostgreSQL SSL module tests.

To regenerate them:

```bash
cd packages/modules/postgresql/src/test-certs
bash generate-certs.sh
```

Optional: pass validity days (default is `36500`):

```bash
bash generate-certs.sh 3650
```

Generated files:

- `ca.crt`
- `server.crt`
- `server.key`
20 changes: 20 additions & 0 deletions packages/modules/postgresql/src/test-certs/ca.crt
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
-----BEGIN CERTIFICATE-----
MIIDLTCCAhWgAwIBAgIUWCfr3fMtUOOZLu1/2Xe8pKjlOC0wDQYJKoZIhvcNAQEL
BQAwJTEjMCEGA1UEAwwadGVzdGNvbnRhaW5lcnMtcG9zdGdyZXMtY2EwIBcNMjYw
MjE3MDkzODQzWhgPMjEyNjAxMjQwOTM4NDNaMCUxIzAhBgNVBAMMGnRlc3Rjb250
YWluZXJzLXBvc3RncmVzLWNhMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKC
AQEAt3lgUG4xAjZ+PcXrd60lNl9OUugjNs4lLM74/X5msS36goT4264MjdDtKYq8
L4iyUMFjp/VMxgor60geQB+A+pT7aJSVOA2ZNwrdMLnFWvziOXTNqIgaUegoXrhb
P3IFbFNlyQiRVT7sZ2YJ4yVZrpvomoXlZ4txxz9WhEGN46txnKTcO2Oj9vgUJwdA
ueUlrEQo6LAMOLFuhPt9+3GNOd+ABKvSOAJTDmhEhXPXQisZHfADgT4nmJwFSuaZ
viFxtnHoxLIru7IRu0S9mIPFRau0CfSufAdpcThv+AQW3qKngNXa2QBVikkD+dJB
OdgjrIpIdNAxTEDPYlgkl4WViQIDAQABo1MwUTAdBgNVHQ4EFgQUUa8kIh73prmS
/tAq3LTcFjhVSTIwHwYDVR0jBBgwFoAUUa8kIh73prmS/tAq3LTcFjhVSTIwDwYD
VR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAdXlDyDvqAgMx/UIAFT60
qKsM2FZf68OYlXsnFE5/Y3BfYx8qtnI+kTOkOvPbMEebfsrWJtI7Qpqy/Ih1Nuis
vgdyy+V41qU2m221MhBh4DCyfZaBJXR8PhFJjXsB0DzDWUj5MoG5ppbf2In1VT2b
prHthUnfzy8TKKZm8APv54Ln8f3Z6OtPsWMfxlXSPN/lZVsEpAuXuDmBfck6cWwl
8OXmYB9ywZv6RqpQvqWEznCnMLJFAe8ELuxo+Jzz/nHfpmYm91QTnsP5KVVghlFF
QDChMXEDRToMjhyk0c87xhLjc0KhUIGeyfFwyDbwMWZOxC0+iifyCTUQHqpTrjE6
aw==
-----END CERTIFICATE-----
56 changes: 56 additions & 0 deletions packages/modules/postgresql/src/test-certs/generate-certs.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
#!/usr/bin/env bash
set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"

CA_CERT="${SCRIPT_DIR}/ca.crt"
SERVER_CERT="${SCRIPT_DIR}/server.crt"
SERVER_KEY="${SCRIPT_DIR}/server.key"

DAYS="${1:-36500}"

TMP_DIR="$(mktemp -d)"
cleanup() {
rm -rf "${TMP_DIR}"
}
trap cleanup EXIT

cat > "${TMP_DIR}/server.ext" <<EOF
subjectAltName=DNS:localhost,IP:127.0.0.1
extendedKeyUsage=serverAuth
EOF

openssl req \
-x509 \
-newkey rsa:2048 \
-nodes \
-keyout "${TMP_DIR}/ca.key" \
-out "${CA_CERT}" \
-days "${DAYS}" \
-subj "/CN=testcontainers-postgres-ca"

openssl req \
-newkey rsa:2048 \
-nodes \
-keyout "${SERVER_KEY}" \
-out "${TMP_DIR}/server.csr" \
-subj "/CN=localhost"

openssl x509 \
-req \
-in "${TMP_DIR}/server.csr" \
-CA "${CA_CERT}" \
-CAkey "${TMP_DIR}/ca.key" \
-CAcreateserial \
-CAserial "${TMP_DIR}/ca.srl" \
-out "${SERVER_CERT}" \
-days "${DAYS}" \
-sha256 \
-extfile "${TMP_DIR}/server.ext"

chmod 600 "${SERVER_KEY}"

echo "Generated:"
echo " ${CA_CERT}"
echo " ${SERVER_CERT}"
echo " ${SERVER_KEY}"
20 changes: 20 additions & 0 deletions packages/modules/postgresql/src/test-certs/server.crt
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
-----BEGIN CERTIFICATE-----
MIIDPDCCAiSgAwIBAgIUeSeLWll7jGddHXy2E+GcFJwa0TowDQYJKoZIhvcNAQEL
BQAwJTEjMCEGA1UEAwwadGVzdGNvbnRhaW5lcnMtcG9zdGdyZXMtY2EwIBcNMjYw
MjE3MDkzODQzWhgPMjEyNjAxMjQwOTM4NDNaMBQxEjAQBgNVBAMMCWxvY2FsaG9z
dDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAKS7T+cI1QDZZ71enQUr
2w9uaVM0niRpAe2SEAX3Re4VFXa3MuW45G3aeiW8phYbNciFVmEUt53dCGZU5Vj5
svXMLRMgxFnk8pIdXt532yjEEOkr4RneFvLGR9fNuaP7lDx3hLEI4CyexmGEyu9I
/S/gzG9fQrCvi8U3zhDYkSEJ/NFVpXRw8dw0kqt+sAPvt2bMWfWYazQEk6hTMyX+
Kw3GDzS+KvCifYNLzZLJZN7YJo/YZ9eeWoh1iBb1zgW3cSu6VQdX0EFHzdWbxWzT
etq5/RXVxZJWq6bGuJQs2/AoT7P63B+EHWOwKjJCEEMApMZu0Y2sTeB/Q5TSpIA+
1IUCAwEAAaNzMHEwGgYDVR0RBBMwEYIJbG9jYWxob3N0hwR/AAABMBMGA1UdJQQM
MAoGCCsGAQUFBwMBMB0GA1UdDgQWBBSIsco32pB4j2Nz+6nHzwFFm9HlpDAfBgNV
HSMEGDAWgBRRryQiHvemuZL+0CrctNwWOFVJMjANBgkqhkiG9w0BAQsFAAOCAQEA
tbqyFfN+Y2tahFflZIS/pcqp0VsDHchzh4sr6iPEcACp5bfNeGcDJAaci5GzbIbX
9lN/1qT827w71/FSCMcIEFM3f6kh7H+16Qqrr8wcuj2DLWNm4Shk+5cAKDd4Sc2S
iWXWOETnVQtabt5PL0JHLkNGGwdo9dAgRIPD0ZA7oUyRXV4bwN6JVup6u5mjDcQ0
WKHNAQWBxJuSjKd7vAgRYTSecn0nMT7RnDBTAjMy2BPehbjUaTNfb/NoJv+vIrxg
cRTP/wG/e+kqH/h0sWyPTvOiURJbDAf8TsAE1Jo7OHgq/MgthpHXWiNMVpu1RhOu
UQEutxKs1AIYFgMYqbDqzA==
-----END CERTIFICATE-----
Loading
Loading