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
67 changes: 51 additions & 16 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -137,22 +137,55 @@ jobs:
e2e-elasticache:
runs-on: ubuntu-latest
needs: unit-tests
# Test ElastiCache handler code path using Redis container
# This validates the ElastiCache configuration without requiring actual AWS infrastructure
services:
redis:
image: redis:7-alpine
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
# Exercises the ElastiCache handler against the two things that actually differ from a
# plain Redis container on a real cluster: in-transit TLS and an auth token. No AWS is
# needed — a self-signed CA trusted via NODE_EXTRA_CA_CERTS lets the factory's `tls: {}`
# (default certificate verification) succeed against a local Redis whose cert covers
# localhost, while `requirepass` stands in for the ElastiCache auth token. The old
# plaintext container (TLS off, no auth) only proved the switch branch, never TLS/AUTH.
env:
ELASTICACHE_AUTH_TOKEN: ci-elasticache-auth-token-0123456789

steps:
- uses: actions/checkout@v4

- name: Generate TLS certificates (self-signed CA + server cert, SAN=localhost)
run: |
mkdir -p "$GITHUB_WORKSPACE/.tls"
cd "$GITHUB_WORKSPACE/.tls"
openssl genrsa -out ca.key 2048
openssl req -x509 -new -nodes -key ca.key -sha256 -days 1 \
-subj "/CN=cache-handler-test-ca" -out ca.crt
openssl genrsa -out redis.key 2048
openssl req -new -key redis.key -subj "/CN=localhost" -out redis.csr
openssl x509 -req -in redis.csr -CA ca.crt -CAkey ca.key -CAcreateserial \
-days 1 -sha256 -out redis.crt \
-extfile <(printf "subjectAltName=DNS:localhost,IP:127.0.0.1")
chmod 644 ca.crt ca.key redis.crt redis.key

- name: Start Redis with TLS + auth token
run: |
docker run -d --name redis-tls -p 6379:6379 \
-v "$GITHUB_WORKSPACE/.tls:/tls:ro" \
redis:7-alpine \
redis-server \
--port 0 \
--tls-port 6379 \
--tls-cert-file /tls/redis.crt \
--tls-key-file /tls/redis.key \
--tls-ca-cert-file /tls/ca.crt \
--tls-auth-clients no \
--requirepass "$ELASTICACHE_AUTH_TOKEN"
echo "Waiting for Redis (TLS + auth) to accept connections..."
for i in $(seq 1 30); do
if docker exec redis-tls redis-cli --tls --cacert /tls/ca.crt \
-a "$ELASTICACHE_AUTH_TOKEN" ping 2>/dev/null | grep -q PONG; then
echo "Redis is up"; exit 0
fi
sleep 1
done
echo "Redis did not become ready"; docker logs redis-tls; exit 1

- name: Setup pnpm
uses: pnpm/action-setup@v4

Expand All @@ -171,22 +204,24 @@ jobs:
- name: Install Playwright browsers
run: pnpm setup:e2e

- name: Build e2e app with ElastiCache handler
- name: Build e2e app with ElastiCache handler (TLS + auth)
env:
CACHE_HANDLER: elasticache
ELASTICACHE_ENDPOINT: localhost
ELASTICACHE_PORT: "6379"
ELASTICACHE_TLS: "false"
ELASTICACHE_TLS: "true"
NODE_EXTRA_CA_CERTS: ${{ github.workspace }}/.tls/ca.crt
run: |
cd apps/e2e-test-app
pnpm build

- name: Run e2e tests with ElastiCache handler
- name: Run e2e tests with ElastiCache handler (TLS + auth)
env:
CACHE_HANDLER: elasticache
ELASTICACHE_ENDPOINT: localhost
ELASTICACHE_PORT: "6379"
ELASTICACHE_TLS: "false"
ELASTICACHE_TLS: "true"
NODE_EXTRA_CA_CERTS: ${{ github.workspace }}/.tls/ca.crt
run: |
cd apps/e2e-test-app
pnpm test:e2e
Expand Down
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,17 @@

All notable changes to this project will be documented in this file. This project follows [Semantic Versioning](https://semver.org/).

## [Unreleased]

### Added

- Unit coverage for the factory's `elasticache` branch — asserts `ELASTICACHE_*` env vars (and explicit options) map to the ioredis config (host/port, TLS-on-by-default, auth-token password, connect timeout, retry) and that a missing endpoint throws.

### Changed

- The `e2e-elasticache` CI job now runs against a Redis container with **in-transit TLS and an auth token** (self-signed CA trusted via `NODE_EXTRA_CA_CERTS`), instead of a plaintext container with TLS off and no auth. This validates the TLS handshake + AUTH path that real ElastiCache requires.
- The e2e test app builds its ElastiCache handler through the package's `createCacheHandler({ type: "elasticache" })` factory rather than hand-wiring ioredis, so the e2e exercises the shipped code path.

## [16.0.0] - 2025-11-18

### Added
Expand Down
61 changes: 8 additions & 53 deletions apps/e2e-test-app/data-cache-handler.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -39,61 +39,16 @@ if (cacheType === "redis") {
debug: process.env.CACHE_DEBUG === "true",
});
} else if (cacheType === "elasticache") {
// ElastiCache handler (password-based auth only)
const Redis = (await import("ioredis")).default;
const { createRedisDataCacheHandler, createIoredisAdapter } = await import(
"@mrjasonroy/cache-components-cache-handler"
);

const endpoint = process.env.ELASTICACHE_ENDPOINT;
const port = Number.parseInt(process.env.ELASTICACHE_PORT || "6379", 10);

if (!endpoint) {
throw new Error("ELASTICACHE_ENDPOINT environment variable is required");
}

const config = {
host: endpoint,
port,
// TLS enabled by default for ElastiCache (disable explicitly with "false")
tls: process.env.ELASTICACHE_TLS !== "false" ? {} : undefined,
connectTimeout: 10000,
retryStrategy: (times) => {
if (times > 3) {
console.error("[ElastiCache] Max retry attempts reached");
return null;
}
return Math.min(times * 200, 2000);
},
};

// Password-based authentication
if (process.env.ELASTICACHE_AUTH_TOKEN) {
console.log("[ElastiCache] Using auth token authentication");
config.password = process.env.ELASTICACHE_AUTH_TOKEN;
} else {
console.log("[ElastiCache] No authentication configured");
}

const ioredisClient = new Redis(config);

ioredisClient.on("error", (err) => {
console.error("[ElastiCache] Connection error:", err);
});

ioredisClient.on("connect", () => {
console.log("[ElastiCache] Connected successfully to", endpoint);
});

// Adapt the ioredis client to the node-redis-style RedisClient contract
// (translates SET { EX } options to ioredis positional args)
const redis = createIoredisAdapter(ioredisClient);

handler = createRedisDataCacheHandler({
redis,
// Drive the PACKAGE factory's elasticache path (zero-config: it reads the ELASTICACHE_*
// env vars itself — endpoint/port, TLS-on-by-default, ELASTICACHE_AUTH_TOKEN). Going
// through createCacheHandler rather than hand-wiring ioredis here means the e2e covers
// the shipped code path, so the TLS + auth round-trip in CI exercises what production runs.
const { createCacheHandler } = await import("@mrjasonroy/cache-components-cache-handler");

handler = createCacheHandler({
type: "elasticache",
keyPrefix: "e2e:cache:",
tagPrefix: "e2e:tags:",
defaultTTL: 86400,
debug: process.env.CACHE_DEBUG === "true",
});
} else {
Expand Down
2 changes: 1 addition & 1 deletion apps/legacy-cache-test/tsconfig.tsbuildinfo

Large diffs are not rendered by default.

89 changes: 89 additions & 0 deletions packages/cache-handler/src/data-cache/factory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,10 @@ describe("createCacheHandler factory", () => {
Reflect.deleteProperty(process.env, "REDIS_URL");
Reflect.deleteProperty(process.env, "VALKEY_URL");
Reflect.deleteProperty(process.env, "REDIS_PASSWORD");
Reflect.deleteProperty(process.env, "ELASTICACHE_ENDPOINT");
Reflect.deleteProperty(process.env, "ELASTICACHE_PORT");
Reflect.deleteProperty(process.env, "ELASTICACHE_TLS");
Reflect.deleteProperty(process.env, "ELASTICACHE_AUTH_TOKEN");
});

test("creates memory handler when type is memory", () => {
Expand Down Expand Up @@ -110,4 +114,89 @@ describe("createCacheHandler factory", () => {

expect(redisConstructorMock).toHaveBeenCalledWith("redis://no-auth");
});

// ElastiCache differs from plain Redis only in how it maps env vars to the ioredis
// config object (host/port instead of a URL, TLS on by default, auth-token password,
// connect timeout + retry). These assert that mapping so the AWS-specific glue can't
// silently break — the matching live round-trip over TLS + auth is the e2e-elasticache
// CI job.
test("maps ELASTICACHE_* env vars to the ioredis config object", () => {
process.env.ELASTICACHE_ENDPOINT = "my-cluster.cache.amazonaws.com";
process.env.ELASTICACHE_PORT = "6380";
process.env.ELASTICACHE_AUTH_TOKEN = "env-token";

createCacheHandler({ type: "elasticache" });

expect(redisConstructorMock).toHaveBeenCalledWith(
expect.objectContaining({
host: "my-cluster.cache.amazonaws.com",
port: 6380,
tls: {},
password: "env-token",
connectTimeout: 10000,
retryStrategy: expect.any(Function),
}),
);
Comment thread
mrjasonroy marked this conversation as resolved.

// Verify the backoff itself, not just that a function was passed: linear
// 200ms * attempt, then give up (null) after 3 attempts.
const config = redisConstructorMock.mock.calls[0][0] as {
retryStrategy: (times: number) => number | null;
};
expect(config.retryStrategy(1)).toBe(200);
expect(config.retryStrategy(3)).toBe(600);
expect(config.retryStrategy(4)).toBeNull();
});

test("explicit options override ElastiCache env vars", () => {
process.env.ELASTICACHE_ENDPOINT = "env-host";
process.env.ELASTICACHE_AUTH_TOKEN = "env-token";

createCacheHandler({
type: "elasticache",
endpoint: "opt-host",
port: 7000,
password: "opt-token",
});

expect(redisConstructorMock).toHaveBeenCalledWith(
expect.objectContaining({
host: "opt-host",
port: 7000,
password: "opt-token",
}),
);
});

test("defaults ElastiCache port to 6379 and TLS on", () => {
process.env.ELASTICACHE_ENDPOINT = "my-cluster";

createCacheHandler({ type: "elasticache" });

expect(redisConstructorMock).toHaveBeenCalledWith(
expect.objectContaining({ port: 6379, tls: {} }),
);
});

test("disables TLS when ELASTICACHE_TLS is 'false'", () => {
process.env.ELASTICACHE_ENDPOINT = "my-cluster";
process.env.ELASTICACHE_TLS = "false";

createCacheHandler({ type: "elasticache" });

expect(redisConstructorMock).toHaveBeenCalledWith(expect.objectContaining({ tls: undefined }));
});
Comment thread
mrjasonroy marked this conversation as resolved.

test("disables TLS when options.tls is false (overrides the on-by-default)", () => {
process.env.ELASTICACHE_ENDPOINT = "my-cluster";

createCacheHandler({ type: "elasticache", tls: false });

expect(redisConstructorMock).toHaveBeenCalledWith(expect.objectContaining({ tls: undefined }));
});

test("throws when no ElastiCache endpoint is configured", () => {
expect(() => createCacheHandler({ type: "elasticache" })).toThrow(/endpoint is required/i);
expect(redisConstructorMock).not.toHaveBeenCalled();
});
});
Loading