chore: improve docker compose template, tests and docs (#11593)

Replaces the single-file `docker-compose.yaml` quickstart with a
production-aware, Traefik-based compose pack in `deploy/compose/`. The
pack covers the full arc from a 2-minute localhost quickstart to a
hardened homelab or semi-production deployment.

### What's in the pack

**Stack**: Traefik (proxy) → ZITADEL API (Go `:8080`) + ZITADEL Login
(Next.js `:3000`) → PostgreSQL

All HTTP/gRPC routing is handled by Traefik via Docker labels — no
manual proxy config needed. The Login V2 UI is enabled by default. Login
URLs are derived automatically from `ZITADEL_DOMAIN`,
`ZITADEL_EXTERNALPORT`, and `ZITADEL_PUBLIC_SCHEME` — no separate URL
variables needed.

**Compose files**

| File | Purpose |
|------|---------|
| `docker-compose.yml` | Base stack — works standalone. Uses explicit
`name: zitadel` network for reliable Traefik service discovery. |
| `docker-compose.mode-letsencrypt.yml` | TLS overlay: ACME HTTP
challenge |
| `docker-compose.mode-external-tls.yml` | TLS overlay: upstream LB/CDN
terminates TLS. Uses `forwardedHeaders.trustedIPs` (configurable via
`TRAEFIK_TRUSTED_IPS`) instead of `insecure=true`. |
| `docker-compose.mode-local-tls.yml` | TLS overlay: self-signed certs
for LAN |
| `docker-compose.prodlike.yml` | Splits init / setup / start for
controlled upgrades |
| `docker-compose.test.yml` | CI overlay: swaps images to locally-built
`:local` tags |

**Optional profiles**: `cache` (Redis), `observability` (OpenTelemetry
Collector)

### Build infra

- New `@zitadel/api:pack` and `@zitadel/login:pack` Nx targets build
local Docker images (`zitadel/zitadel:local`,
`zitadel/zitadel-login:local`) for use in CI and local testing
- `apps/api/Dockerfile` now accepts a `BINARY` build arg so local and
release builds share the same image

### Testing

- New `@zitadel/compose` Nx project with targets: `test-config`
(validates all overlay combinations using `--quiet`), `test-run` (starts
full stack with local images), `test-e2e` (Playwright wiring + protocol
matrix tests through Traefik), `test-full` (end-to-end: build → start →
test → teardown), `stop`
- **`@zitadel/compose` is explicitly excluded from `nx affected` in CI
for now** — the full stack smoke test requires a Docker daemon and
significant resources. The intent is to add a dedicated
`compose_smoke_test` CI job in a follow-up. The targets can be run
locally with `pnpm nx run @zitadel/compose:test-full`.

### Documentation

- **`compose.mdx`**: Complete rewrite with a staged structure (Stage 1
Quickstart → Stage 2 Homelab → Stage 3 Beyond Compose). Documents TLS
modes, profiles, secrets hardening, ExternalDomain/Port/Secure
invariant, upgrades, and the path to Kubernetes
- **New `requirements.mdx`**: Lists supported PostgreSQL versions
(14–18), Redis (standalone), Docker Compose v2.x, and reverse proxy h2c
requirements
- **`reverse_proxy.mdx`**: Added intro covering h2c requirements, TLS
modes table, and Login UI routing split
- **`troubleshooting.mdx`**: New sections for container restarts on
upgrade, FIRSTINSTANCE env vars not taking effect, and diagnosing
unhealthy containers
- **`caddy/index.mdx`**: Known issue and workaround for the `TE:
trailers` header hang
- Removed the old
`apps/docs/content/self-hosting/deploy/docker-compose.yaml` embedded in
the docs

### Breaking change

The old `apps/docs/content/self-hosting/deploy/docker-compose.yaml` file
is deleted. The getting-started docs page
(`/self-hosting/deploy/compose`) now points to the new pack via a `curl
| tar` download command.

---

### Checklist

- [x] `deploy/compose/` smoke test passes end-to-end locally (`pnpm nx
run @zitadel/compose:test-full`)
- [x] Docs build passes (`pnpm nx run @zitadel/docs:build`)
- [ ] Follow-up issue created to add `compose_smoke_test` CI job

---------

Co-authored-by: Mridang Agarwalla <[email protected]>
This commit is contained in:
Florian Forster
2026-02-24 16:33:12 -08:00
committed by GitHub
co-authored by Mridang Agarwalla
parent 54095b64b1
commit bac224c56d
38 changed files with 1968 additions and 210 deletions
+61
View File
@@ -0,0 +1,61 @@
/**
* Compose stack wiring smoke tests
*
* PURPOSE: verify that all compose services are correctly wired together.
* These are INFRASTRUCTURE tests, not login-feature tests.
* They answer: "does the compose stack work end-to-end?"
*
* Test path: browser → Traefik (port 8888) → zitadel-login → zitadel-api → postgres
* A single successful login proves every link in that chain is functioning.
*
* For feature-level login tests (MFA, OIDC flows, IdP, etc.) see:
* apps/login/acceptance/ – run via @zitadel/login:test-acceptance
*
* Credentials are read from SMOKE_TEST_ADMIN_USERNAME / SMOKE_TEST_ADMIN_PASSWORD
* (set in .env.test). The fallback values below match the defaults in .env.test and
* docker-compose.test.yml — they are intentional so the test works out-of-the-box
* without any extra configuration. If you change the seeded admin password, update
* .env.test accordingly.
* The initial admin password is seeded via ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORD
* in docker-compose.test.yml.
*/
import { expect, test } from "@playwright/test";
const username =
process.env.SMOKE_TEST_ADMIN_USERNAME ?? "[email protected]";
const password = process.env.SMOKE_TEST_ADMIN_PASSWORD ?? "Password1!";
test.describe("compose stack wiring", () => {
test("admin can complete username/password login (proves Traefik → Login → API → DB chain)", async ({
page,
}) => {
// Step 1 – enter login name.
// Use a long timeout for the first navigation: even though the compose stack
// is healthy, ZITADEL's LoginName page makes server-side gRPC calls to the
// API on first render and can be slow when the instance is still warming up.
await page.goto("/ui/v2/login/loginname");
// The login page renders two inputs with the same test ID (one with autofocus,
// one without — likely an accessibility duplicate). Use .first() to avoid a
// strict-mode violation and target the active, focused input.
const usernameInput = page.getByTestId("username-text-input").first();
await usernameInput.waitFor({ state: "visible", timeout: 120_000 });
await usernameInput.fill(username);
// Wait for the button to be enabled before clicking — the page may still be
// hydrating when the input becomes visible.
await page.getByTestId("submit-button").first().waitFor({ state: "visible" });
await page.getByTestId("submit-button").first().click();
// Step 2 – enter password.
// Wait for the password page to fully render before typing; clicking submit
// above triggers a navigation and the input must be present and interactive.
const passwordInput = page.getByTestId("password-text-input").first();
await passwordInput.waitFor({ state: "visible", timeout: 30_000 });
await passwordInput.fill(password);
await page.getByTestId("submit-button").first().waitFor({ state: "visible" });
await page.getByTestId("submit-button").first().click();
// Step 3 – assert signed-in page (ZITADEL redirects here after a successful
// direct login that has no original auth-request to return to).
await expect(page).toHaveURL(/signedin/, { timeout: 30_000 });
});
});
+558
View File
@@ -0,0 +1,558 @@
/**
* Compose stack wiring tests — per-service reachability through Traefik
*
* PURPOSE: verify that every service and protocol is correctly routed through
* the Traefik reverse proxy. These are INFRASTRUCTURE tests, not feature tests.
* They answer: "is this service alive and reachable through the proxy?"
*
* Services covered:
* - Login UI (zitadel-login, Next.js, /ui/v2/login)
* - Console (zitadel-api, Angular SPA, /ui/console)
* - OIDC (zitadel-api, /.well-known/openid-configuration + /oauth/v2/keys)
* - SAML (zitadel-api, /saml/v2/metadata)
* - Root redirect (Traefik replacepath middleware → /ui/v2/login/)
*
* API protocol × transport matrix (all via Traefik catch-all, priority 100):
* V1 (AdminService/Healthz — unauthenticated, expects success):
* - REST json HTTP/1.1 /admin/v1/healthz
* - REST json h2c /admin/v1/healthz
* - gRPC-web proto HTTP/1.1 /zitadel.admin.v1.AdminService/Healthz
* - gRPC-web json HTTP/1.1 /zitadel.admin.v1.AdminService/Healthz
* - gRPC proto h2c /zitadel.admin.v1.AdminService/Healthz
* - gRPC json h2c /zitadel.admin.v1.AdminService/Healthz
*
* V2 (SessionService/ListSessions — requires auth, expects 401):
* Three access styles, each × HTTP/1.1 and h2c:
*
* 1. REST-style path application/json
* POST /v2/sessions (gRPC-gateway REST mapping)
*
* 2. gRPC-style path application/json (Connect unary JSON)
* POST /zitadel.session.v2.SessionService/ListSessions
* Handled by the Connect handler; application/json = Connect unary.
* Note: application/connect+json is streaming-only (Connect spec).
*
* 3. gRPC-style path application/proto (Connect unary proto)
* POST /zitadel.session.v2.SessionService/ListSessions
* Handled by the Connect handler; application/proto = Connect unary.
* Note: application/connect+proto is streaming-only (Connect spec).
*
* 4. gRPC-style path application/grpc+proto / application/grpc+json (gRPC native, h2c only)
* POST /zitadel.session.v2.SessionService/ListSessions
*
* For end-to-end browser login flow (proves Traefik → Login → API → DB chain):
* see smoke.spec.ts
*
* For feature-level login tests (MFA, OIDC flows, IdPs, etc.):
* see apps/login/acceptance/ — run via @zitadel/login:test-acceptance
*/
import { expect, test, type APIRequestContext, type TestInfo } from "@playwright/test";
import http2 from "node:http2";
import { Buffer } from "node:buffer";
// ---------------------------------------------------------------------------
// HTTP/2 (h2c) helpers
// ---------------------------------------------------------------------------
type H2Response = {
status: number;
headers: Record<string, string>;
body: Buffer;
trailers: Record<string, string>;
};
function h2Request(
baseUrl: string,
method: string,
path: string,
reqHeaders: Record<string, string>,
body?: Buffer | string,
): Promise<H2Response> {
return new Promise((resolve, reject) => {
const client = http2.connect(baseUrl);
const timeout = setTimeout(() => {
client.destroy();
reject(new Error(`h2c ${method} ${path} timed out`));
}, 30_000);
client.once("error", (err) => {
clearTimeout(timeout);
reject(err);
});
const req = client.request({
":method": method,
":path": path,
":scheme": "http",
...reqHeaders,
});
if (body !== undefined) req.write(body);
req.end();
let status = 0;
const chunks: Buffer[] = [];
const trailers: Record<string, string> = {};
const responseHeaders: Record<string, string> = {};
req.on("response", (hdrs) => {
status = Number(hdrs[":status"]);
for (const [k, v] of Object.entries(hdrs)) {
if (!k.startsWith(":")) responseHeaders[k] = String(v);
}
});
req.on("data", (chunk: Buffer) => chunks.push(chunk));
req.on("trailers", (thdrs) => {
for (const [k, v] of Object.entries(thdrs)) {
trailers[k] = String(v);
}
});
req.on("end", () => {
clearTimeout(timeout);
client.close();
resolve({
status,
headers: responseHeaders,
body: Buffer.concat(chunks),
trailers,
});
});
req.on("error", (err) => {
clearTimeout(timeout);
client.close();
reject(err);
});
});
}
function h2Post(
baseUrl: string,
path: string,
reqHeaders: Record<string, string>,
body?: Buffer | string,
): Promise<H2Response> {
return h2Request(baseUrl, "POST", path, reqHeaders, body);
}
function h2Get(
baseUrl: string,
path: string,
reqHeaders: Record<string, string> = {},
): Promise<H2Response> {
return h2Request(baseUrl, "GET", path, reqHeaders);
}
// ---------------------------------------------------------------------------
// gRPC framing helpers (RFC: https://grpc.github.io/grpc/core/md_doc_PROTOCOL-HTTP2.html)
// Frame: [compressed (1 byte)] [message length (4 bytes BE)] [message bytes]
// ---------------------------------------------------------------------------
/** Wrap proto bytes in a gRPC length-prefixed frame. */
function grpcFrame(protoBytes: Buffer = Buffer.alloc(0)): Buffer {
const frame = Buffer.allocUnsafe(5 + protoBytes.length);
frame[0] = 0; // compressed-flag = false
frame.writeUInt32BE(protoBytes.length, 1);
protoBytes.copy(frame, 5);
return frame;
}
/** Parse the first gRPC response frame and return the proto body bytes. */
function parseGrpcFrame(buf: Buffer): Buffer {
if (buf.length < 5) throw new Error(`gRPC response frame too short (${buf.length} bytes)`);
const len = buf.readUInt32BE(1);
if (buf.length < 5 + len) throw new Error(`gRPC response frame truncated: expected ${5 + len} bytes, got ${buf.length}`);
return buf.subarray(5, 5 + len);
}
/** Parse gRPC-web inline trailers from the response body.
* After the data frame(s), gRPC-web appends a trailer frame with flag 0x80. */
function parseGrpcWebTrailers(buf: Buffer): Record<string, string> {
// Skip past data frames to find the trailer frame (flag byte 0x80)
let offset = 0;
while (offset < buf.length) {
if (offset + 5 > buf.length) {
throw new Error(`Truncated gRPC-web frame at offset ${offset}: need 5 header bytes but only ${buf.length - offset} remain`);
}
const flag = buf[offset];
const len = buf.readUInt32BE(offset + 1);
if (flag === 0x80) {
// Trailer frame: ASCII key-value pairs separated by \r\n
const text = buf.subarray(offset + 5, offset + 5 + len).toString("utf8");
const trailers: Record<string, string> = {};
for (const line of text.split("\r\n")) {
const colon = line.indexOf(":");
if (colon > 0) {
trailers[line.slice(0, colon).trim()] = line.slice(colon + 1).trim();
}
}
return trailers;
}
offset += 5 + len;
}
return {};
}
// ---------------------------------------------------------------------------
// Soft-fail wrapper — allows new/experimental permutations to warn on failure
// without breaking CI. Tests marked knownWorking: false log a warning instead
// of hard-failing.
// ---------------------------------------------------------------------------
function softTest(
label: string,
knownWorking: boolean,
testFn: (args: { request: APIRequestContext; testInfo: TestInfo }) => Promise<void>,
) {
if (knownWorking) {
test(label, async ({ request }, testInfo) => {
await testFn({ request, testInfo });
});
} else {
test(label, async ({ request }, testInfo) => {
try {
await testFn({ request, testInfo });
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
testInfo.annotations.push({
type: "fixme",
description: `[soft-fail] ${msg}`,
});
// eslint-disable-next-line no-console
console.warn(`⚠ SOFT-FAIL: ${label}\n ${msg}`);
}
});
}
}
// ---------------------------------------------------------------------------
// Protocol × transport permutation types
// ---------------------------------------------------------------------------
type Transport = "http1" | "h2c";
type Protocol = "rest" | "grpc-web" | "grpc" | "connectrpc";
type Encoding = "proto" | "json";
type ApiPermutation = {
protocol: Protocol;
encoding: Encoding;
transport: Transport;
/** Whether this permutation is already known to work (hard-fail on regression). */
knownWorking: boolean;
};
// ---------------------------------------------------------------------------
// V1 API permutations — AdminService/Healthz (unauthenticated, expects 200/OK)
// ---------------------------------------------------------------------------
const V1_GRPC_PATH = "/zitadel.admin.v1.AdminService/Healthz";
const V1_REST_PATH = "/admin/v1/healthz";
const v1Permutations: ApiPermutation[] = [
// REST
{ protocol: "rest", encoding: "json", transport: "http1", knownWorking: true },
{ protocol: "rest", encoding: "json", transport: "h2c", knownWorking: true },
// gRPC-web (HTTP/1.1 only — grpc-web is designed for browser HTTP/1.1)
{ protocol: "grpc-web", encoding: "proto", transport: "http1", knownWorking: true },
{ protocol: "grpc-web", encoding: "json", transport: "http1", knownWorking: true },
// gRPC native (h2c only — requires HTTP/2)
{ protocol: "grpc", encoding: "proto", transport: "h2c", knownWorking: true },
{ protocol: "grpc", encoding: "json", transport: "h2c", knownWorking: true },
];
// ---------------------------------------------------------------------------
// V2 API permutations — SessionService/ListSessions (requires auth → 401)
// ---------------------------------------------------------------------------
const V2_GRPC_PATH = "/zitadel.session.v2.SessionService/ListSessions";
const V2_REST_PATH = "/v2/sessions";
const v2Permutations: ApiPermutation[] = [
// REST (gRPC-gateway)
{ protocol: "rest", encoding: "json", transport: "http1", knownWorking: true },
{ protocol: "rest", encoding: "json", transport: "h2c", knownWorking: true },
// ConnectRPC (v2 only — v1 does not register Connect handlers)
{ protocol: "connectrpc", encoding: "proto", transport: "http1", knownWorking: true },
{ protocol: "connectrpc", encoding: "json", transport: "http1", knownWorking: true },
{ protocol: "connectrpc", encoding: "proto", transport: "h2c", knownWorking: true },
{ protocol: "connectrpc", encoding: "json", transport: "h2c", knownWorking: true },
// gRPC native (h2c only)
{ protocol: "grpc", encoding: "proto", transport: "h2c", knownWorking: true },
{ protocol: "grpc", encoding: "json", transport: "h2c", knownWorking: true },
];
// ---------------------------------------------------------------------------
// Tests — service-level reachability (non-API)
// ---------------------------------------------------------------------------
// The base URL comes from playwright.config.ts (PROXY_HTTP_PUBLISHED_PORT, default 8888).
// We resolve it here so h2Post()/h2Get() can connect to the same host.
const BASE =
process.env.PLAYWRIGHT_BASE_URL ??
`http://localhost:${process.env.PROXY_HTTP_PUBLISHED_PORT ?? "8888"}`;
test.describe("traefik routing", () => {
test("GET / → redirects to /ui/v2/login/", async ({ request }) => {
// Playwright follows redirects by default; verify the final destination.
// Note: Next.js normalises /ui/v2/login/ → /ui/v2/login (strips trailing slash).
const resp = await request.get("/");
expect(resp.url()).toContain("/ui/v2/login");
expect(resp.status()).toBe(200);
});
});
test.describe("login ui", () => {
test("GET /ui/v2/login/healthy → 200", async ({ request }) => {
const resp = await request.get("/ui/v2/login/healthy");
expect(resp.status()).toBe(200);
});
test("GET /ui/v2/login/ → 200 HTML", async ({ request }) => {
const resp = await request.get("/ui/v2/login/");
expect(resp.status()).toBe(200);
expect(await resp.text()).toContain("<html");
});
});
test.describe("console", () => {
test("GET /ui/console/ → 200 HTML (Angular SPA shell)", async ({ request }) => {
const resp = await request.get("/ui/console/");
expect(resp.status()).toBe(200);
expect(await resp.text()).toContain("<html");
});
});
test.describe("oidc", () => {
test("GET /.well-known/openid-configuration → valid discovery document", async ({ request }) => {
const resp = await request.get("/.well-known/openid-configuration");
expect(resp.status()).toBe(200);
const doc = await resp.json();
expect(doc).toHaveProperty("issuer");
expect(doc).toHaveProperty("authorization_endpoint");
expect(doc).toHaveProperty("token_endpoint");
expect(doc).toHaveProperty("jwks_uri");
});
test("GET /oauth/v2/keys → JWKS with keys array", async ({ request }) => {
const resp = await request.get("/oauth/v2/keys");
expect(resp.status()).toBe(200);
const body = await resp.json();
expect(body).toHaveProperty("keys");
expect(Array.isArray(body.keys)).toBe(true);
});
});
test.describe("saml", () => {
test("GET /saml/v2/metadata → EntityDescriptor XML", async ({ request }) => {
const resp = await request.get("/saml/v2/metadata");
expect(resp.status()).toBe(200);
expect(await resp.text()).toContain("EntityDescriptor");
});
});
test.describe("api v1 rest extras", () => {
// Additional REST tests beyond the matrix: all three v1 services + /api alias.
for (const svc of ["management", "admin", "auth"]) {
test(`GET /${svc}/v1/healthz → 200`, async ({ request }) => {
const resp = await request.get(`/${svc}/v1/healthz`);
expect(resp.status()).toBe(200);
});
}
test("GET /api/admin/v1/healthz via /api alias → 200 (tests Traefik strip-prefix middleware)", async ({ request }) => {
const resp = await request.get("/api/admin/v1/healthz");
expect(resp.status()).toBe(200);
});
});
// ---------------------------------------------------------------------------
// API V1 protocol × transport matrix
//
// Endpoint: AdminService/Healthz (unauthenticated)
// Expected: success (REST → 200, gRPC → grpc-status 0, gRPC-web → grpc-status 0)
// ---------------------------------------------------------------------------
test.describe("api v1 protocol matrix", () => {
for (const p of v1Permutations) {
const label = `v1 ${p.protocol} ${p.encoding} ${p.transport}`;
softTest(label, p.knownWorking, async ({ request }) => {
switch (p.protocol) {
// ----- REST (gRPC-gateway) -----
case "rest": {
if (p.transport === "http1") {
const resp = await request.get(V1_REST_PATH);
expect(resp.status()).toBe(200);
} else {
const resp = await h2Get(BASE, V1_REST_PATH);
expect(resp.status).toBe(200);
}
break;
}
// ----- gRPC-web (HTTP/1.1) -----
case "grpc-web": {
const contentType =
p.encoding === "proto"
? "application/grpc-web+proto"
: "application/grpc-web+json";
// For json encoding, Healthz request is empty → JSON body is '{}'
const reqBody =
p.encoding === "proto"
? grpcFrame()
: grpcFrame(Buffer.from("{}"));
const resp = await request.post(V1_GRPC_PATH, {
headers: {
"content-type": contentType,
"x-grpc-web": "1",
},
data: reqBody,
});
expect(resp.status()).toBe(200);
// For proto encoding: parse inline trailers and assert grpc-status 0.
// For json encoding: the Go gRPC server has no JSON codec registered and
// returns no well-formed gRPC-web trailer frame. HTTP 200 alone proves
// the request was routed to the backend by Traefik.
if (p.encoding === "proto") {
const buf = Buffer.from(await resp.body());
const trailers = parseGrpcWebTrailers(buf);
expect(trailers["grpc-status"]).toBe("0");
}
break;
}
// ----- gRPC native (h2c) -----
case "grpc": {
const contentType =
p.encoding === "proto"
? "application/grpc+proto"
: "application/grpc+json";
const reqBody =
p.encoding === "proto"
? grpcFrame()
: grpcFrame(Buffer.from("{}"));
const resp = await h2Post(BASE, V1_GRPC_PATH, {
"content-type": contentType,
te: "trailers",
}, reqBody);
expect(resp.status).toBe(200);
// For proto encoding: assert OK (grpc-status 0).
// For json encoding: the Go gRPC server has no JSON codec registered,
// so it returns INTERNAL (13). We only assert the status is defined,
// which proves the request was routed to the backend.
if (p.encoding === "proto") {
expect(resp.trailers["grpc-status"]).toBe("0");
} else {
expect(resp.trailers["grpc-status"]).toBeDefined();
}
break;
}
default:
throw new Error(`unexpected protocol: ${p.protocol}`);
}
});
}
});
// ---------------------------------------------------------------------------
// API V2 protocol × transport matrix
//
// Endpoint: SessionService/ListSessions (requires auth)
// Expected: 401 / UNAUTHENTICATED
// - REST (gRPC-gateway): HTTP 401, body.code === 16
// - ConnectRPC unary: HTTP 401, body.code === "unauthenticated"
// - gRPC native: HTTP 200, grpc-status trailer === "16"
// ---------------------------------------------------------------------------
test.describe("api v2 protocol matrix", () => {
for (const p of v2Permutations) {
const label = `v2 ${p.protocol} ${p.encoding} ${p.transport}`;
softTest(label, p.knownWorking, async ({ request }) => {
switch (p.protocol) {
// ----- REST (gRPC-gateway) -----
case "rest": {
if (p.transport === "http1") {
const resp = await request.post(V2_REST_PATH, {
headers: { "content-type": "application/json" },
data: "{}",
});
expect(resp.status()).toBe(401);
const body = await resp.json();
expect(body.code).toBe(16); // UNAUTHENTICATED
} else {
const resp = await h2Post(BASE, V2_REST_PATH, {
"content-type": "application/json",
}, "{}");
expect(resp.status).toBe(401);
const body = JSON.parse(resp.body.toString("utf8"));
expect(body.code).toBe(16);
}
break;
}
// ----- Connect protocol unary (v2 only — v1 does not register Connect handlers) -----
// Per https://connectrpc.com/docs/multi-protocol/:
// application/json → Connect unary JSON (gRPC-style path)
// application/proto → Connect unary proto (gRPC-style path)
// application/connect+json and application/connect+proto are STREAMING-only.
// Connect unary uses no gRPC length-prefix framing; body is plain JSON or proto bytes.
// Error response format: { "code": "unauthenticated", ... }
case "connectrpc": {
const contentType =
p.encoding === "proto"
? "application/proto"
: "application/json";
// For connect+json, send a JSON body; for connect+proto, send empty bytes
const reqBody = p.encoding === "json" ? "{}" : Buffer.alloc(0);
if (p.transport === "http1") {
const resp = await request.post(V2_GRPC_PATH, {
headers: { "content-type": contentType },
data: reqBody,
});
// ConnectRPC returns the HTTP status directly
expect(resp.status()).toBe(401);
const body = await resp.json();
expect(body.code).toBe("unauthenticated");
} else {
const resp = await h2Post(BASE, V2_GRPC_PATH, {
"content-type": contentType,
}, reqBody);
expect(resp.status).toBe(401);
const body = JSON.parse(resp.body.toString("utf8"));
expect(body.code).toBe("unauthenticated");
}
break;
}
// ----- gRPC native (h2c) -----
case "grpc": {
const contentType =
p.encoding === "proto"
? "application/grpc+proto"
: "application/grpc+json";
const reqBody =
p.encoding === "proto"
? grpcFrame()
: grpcFrame(Buffer.from("{}"));
const resp = await h2Post(BASE, V2_GRPC_PATH, {
"content-type": contentType,
te: "trailers",
}, reqBody);
// gRPC always returns HTTP 200; error is in grpc-status trailer
expect(resp.status).toBe(200);
expect(resp.trailers["grpc-status"]).toBe("16"); // UNAUTHENTICATED
break;
}
default:
throw new Error(`unexpected protocol: ${p.protocol}`);
}
});
}
});