test(e2e): add SAML discover, metadata, and login route tests

Three test files exercising the SAML routes that don't require a full
IdP round-trip:

- discover.test.ts: 5 cases for /auth/saml/discover — happy path,
  unknown domain (404), case-insensitivity, unknown project_id,
  cross-project isolation (project A's connection isn't visible from
  a query against project B's domain).

- metadata.test.ts: 3 cases for /auth/saml/metadata — XML contains
  entityID + ACS URL embedded, 404 for unknown connection, 404 when
  connection exists but has no IdP cert (incomplete configuration).

- login.test.ts: 5 cases for /auth/saml/login — JSON-mode returns
  the IdP redirect URL with SAMLRequest+RelayState, browser-redirect
  mode sets the stack-saml-inner- CSRF cookie, 404 unknown connection,
  403 when allowSignIn=false, invalid client_id rejected.

Test integrity: all tests drive the API only — no imports from
apps/backend/src/saml/. SAML config is set via the standard config
override endpoint (no test-only mutator), so the routes run through
the same code path real customers would hit.

Full SAML round-trip tests (login → mock IdP → ACS → session)
deferred to a follow-up — they need a sequenced flow against the
mock-saml-idp service that's separate from these endpoint-level tests.
This commit is contained in:
Bilal Godil 2026-04-29 15:47:13 -07:00
parent 958407d0c3
commit f8093a31c1
3 changed files with 284 additions and 0 deletions

View File

@ -0,0 +1,97 @@
import { it } from "../../../../../../helpers";
import { Project, niceBackendFetch } from "../../../../../backend-helpers";
/**
* Tests for GET /auth/saml/discover.
*
* Test integrity: drives the API only no imports from
* apps/backend/src/saml/. Project config is set via the standard config
* override endpoint (no special test-only mutator), so the discovery
* lookup runs through the same code path real customers would hit.
*
* Connection isolation note: each `it` block creates its own project via
* Project.createAndSwitch, so connections don't leak across tests.
*/
async function createProjectWithSamlConnection(slug: string, domain: string) {
const { projectId } = await Project.createAndSwitch();
// Push the SAML connection at the environment level — that's where the
// IdP-side fields live. The discovery endpoint reads from the rendered
// organization config which folds in env overrides.
await Project.updateConfig({
[`auth.saml.connections.${slug}.displayName`]: `${slug} SSO`,
[`auth.saml.connections.${slug}.allowSignIn`]: true,
[`auth.saml.connections.${slug}.domain`]: domain,
[`auth.saml.connections.${slug}.idpEntityId`]: `https://idp.${domain}/saml/metadata`,
[`auth.saml.connections.${slug}.idpSsoUrl`]: `https://idp.${domain}/saml/sso`,
[`auth.saml.connections.${slug}.idpCertificate`]: "MIICertificatePlaceholderForDiscoveryTest=",
});
return { projectId };
}
it("returns the matching connection for a known email domain", async ({ expect }) => {
const { projectId } = await createProjectWithSamlConnection("acme", "acme.test");
const response = await niceBackendFetch(
`/api/v1/auth/saml/discover?email=alice@acme.test&project_id=${projectId}`,
{ method: "GET" },
);
expect(response).toMatchInlineSnapshot(`
NiceResponse {
"status": 200,
"body": {
"connection_id": "acme",
"display_name": "acme SSO",
},
"headers": Headers { <some fields may have been hidden> },
}
`);
});
it("returns 404 when no connection matches the email's domain", async ({ expect }) => {
const { projectId } = await createProjectWithSamlConnection("acme", "acme.test");
const response = await niceBackendFetch(
`/api/v1/auth/saml/discover?email=stranger@unknown.test&project_id=${projectId}`,
{ method: "GET" },
);
expect(response.status).toBe(404);
});
it("matches the connection case-insensitively on the email domain", async ({ expect }) => {
const { projectId } = await createProjectWithSamlConnection("acme", "acme.test");
const response = await niceBackendFetch(
`/api/v1/auth/saml/discover?email=ALICE@ACME.TEST&project_id=${projectId}`,
{ method: "GET" },
);
expect(response.status).toBe(200);
expect(response.body).toEqual({ connection_id: "acme", display_name: "acme SSO" });
});
it("returns 404 for an unknown project_id", async ({ expect }) => {
await createProjectWithSamlConnection("acme", "acme.test");
const response = await niceBackendFetch(
`/api/v1/auth/saml/discover?email=alice@acme.test&project_id=00000000-0000-0000-0000-000000000000`,
{ method: "GET" },
);
expect(response.status).toBe(404);
});
it("isolates connections across projects (B's connection is not visible from A)", async ({ expect }) => {
// Project A has acme; project B has globex. Querying A for globex's domain must miss.
const { projectId: projectA } = await createProjectWithSamlConnection("acme", "acme.test");
await createProjectWithSamlConnection("globex", "globex.test");
const response = await niceBackendFetch(
`/api/v1/auth/saml/discover?email=bob@globex.test&project_id=${projectA}`,
{ method: "GET" },
);
expect(response.status).toBe(404);
});

View File

@ -0,0 +1,115 @@
import { it } from "../../../../../../helpers";
import { InternalApiKey, Project, backendContext, niceBackendFetch } from "../../../../../backend-helpers";
/**
* Tests for GET /auth/saml/login/[connection_id].
*
* Verifies the SDK-facing endpoint that begins SP-initiated SSO. We don't
* follow the redirect into the IdP here that's the round-trip test
* (deferred). These tests check:
* - URL is built correctly and points at the configured IdP SSO URL
* - SAMLRequest + RelayState query params are present
* - CSRF cookie is set in browser-redirect mode
* - JSON-mode response returns the location instead of redirecting
* - Error paths: invalid client, unknown connection, sign-in disabled
*/
async function setupProjectWithSamlConnection(slug: string, idpHost: string) {
await Project.createAndSwitch();
await InternalApiKey.createAndSetProjectKeys();
await Project.updateConfig({
[`auth.saml.connections.${slug}.displayName`]: `${slug} SSO`,
[`auth.saml.connections.${slug}.allowSignIn`]: true,
[`auth.saml.connections.${slug}.idpEntityId`]: `https://${idpHost}/saml/metadata`,
[`auth.saml.connections.${slug}.idpSsoUrl`]: `https://${idpHost}/saml/sso`,
[`auth.saml.connections.${slug}.idpCertificate`]: "MIICertificatePlaceholderForLoginTest=",
});
}
function loginQuery() {
const projectKeys = backendContext.value.projectKeys;
if (projectKeys === "no-project") throw new Error("No project keys");
const branchId = backendContext.value.currentBranchId;
return {
client_id: !branchId ? projectKeys.projectId : `${projectKeys.projectId}#${branchId}`,
client_secret: projectKeys.publishableClientKey ?? "",
redirect_uri: "http://localhost:8101/handler/oauth-callback",
scope: "legacy",
state: "this-is-some-state",
grant_type: "authorization_code",
code_challenge: "some-code-challenge",
code_challenge_method: "S256",
response_type: "code",
};
}
it("returns the IdP SSO URL with SAMLRequest in JSON-mode", async ({ expect }) => {
await setupProjectWithSamlConnection("acme", "idp.acme.test");
const response = await niceBackendFetch("/api/v1/auth/saml/login/acme", {
method: "GET",
query: { ...loginQuery(), stack_response_mode: "json" },
});
expect(response.status).toBe(200);
expect(typeof (response.body as { location?: unknown }).location).toBe("string");
const location = new URL((response.body as { location: string }).location);
expect(location.host).toBe("idp.acme.test");
expect(location.pathname).toBe("/saml/sso");
expect(location.searchParams.get("SAMLRequest")).toBeTruthy();
expect(location.searchParams.get("RelayState")).toBe("this-is-some-state");
});
it("redirects + sets CSRF cookie in browser-redirect mode", async ({ expect }) => {
await setupProjectWithSamlConnection("acme", "idp.acme.test");
const response = await niceBackendFetch("/api/v1/auth/saml/login/acme", {
method: "GET",
redirect: "manual",
query: loginQuery(),
});
expect(response.status).toBe(307);
const location = response.headers.get("location");
expect(location).toBeTruthy();
expect(new URL(location!).host).toBe("idp.acme.test");
// CSRF cookie keyed to the AuthnRequest ID — snapshot serializer strips
// the suffix via the keyedCookieNamePrefixes registration.
expect(response.headers.get("set-cookie")).toMatch(/^stack-saml-inner-[^;]+=true;/);
});
it("returns 404 for an unknown connection ID", async ({ expect }) => {
await setupProjectWithSamlConnection("acme", "idp.acme.test");
const response = await niceBackendFetch("/api/v1/auth/saml/login/does-not-exist", {
method: "GET",
query: loginQuery(),
});
expect(response.status).toBe(404);
});
it("returns 403 when allowSignIn is false on the connection", async ({ expect }) => {
await setupProjectWithSamlConnection("acme", "idp.acme.test");
await Project.updateConfig({ "auth.saml.connections.acme.allowSignIn": false });
const response = await niceBackendFetch("/api/v1/auth/saml/login/acme", {
method: "GET",
query: loginQuery(),
});
expect(response.status).toBe(403);
});
it("rejects an invalid client_id", async ({ expect }) => {
await setupProjectWithSamlConnection("acme", "idp.acme.test");
const response = await niceBackendFetch("/api/v1/auth/saml/login/acme", {
method: "GET",
query: { ...loginQuery(), client_id: "00000000-0000-0000-0000-000000000000" },
});
// Tenancy lookup fails → InvalidOAuthClientIdOrSecret known error.
expect(response.status).not.toBe(200);
expect(response.status).not.toBe(307);
});

View File

@ -0,0 +1,72 @@
import { it } from "../../../../../../helpers";
import { Project, niceBackendFetch } from "../../../../../backend-helpers";
/**
* Tests for GET /auth/saml/metadata/[connection_id].
*
* The IdP admin fetches this URL to wire up the SP side. We assert the
* returned XML contains entityID + AssertionConsumerService URLs that
* match what the IdP would actually need.
*/
async function setupSamlConnection(slug: string) {
const { projectId } = await Project.createAndSwitch();
await Project.updateConfig({
[`auth.saml.connections.${slug}.displayName`]: `${slug} SSO`,
[`auth.saml.connections.${slug}.allowSignIn`]: true,
[`auth.saml.connections.${slug}.idpEntityId`]: `https://idp.${slug}.test/saml/metadata`,
[`auth.saml.connections.${slug}.idpSsoUrl`]: `https://idp.${slug}.test/saml/sso`,
[`auth.saml.connections.${slug}.idpCertificate`]: "MIICertificatePlaceholderForMetadataTest=",
});
return { projectId };
}
it("returns SP metadata XML with entityID and ACS URL embedded", async ({ expect }) => {
const { projectId } = await setupSamlConnection("acme");
const response = await niceBackendFetch(
`/api/v1/auth/saml/metadata/acme?project_id=${projectId}`,
{ method: "GET" },
);
expect(response.status).toBe(200);
expect(typeof response.body).toBe("string");
const xml = response.body as string;
// entityID should reference our metadata URL.
expect(xml).toContain('entityID="');
expect(xml).toContain("/api/v1/auth/saml/metadata/acme");
// AssertionConsumerService should point at the ACS endpoint.
expect(xml).toContain("/api/v1/auth/saml/acs/acme");
// Must declare the HTTP-POST binding for the IdP to know where to POST.
expect(xml).toContain("urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST");
});
it("returns 404 for an unknown connection ID", async ({ expect }) => {
const { projectId } = await setupSamlConnection("acme");
const response = await niceBackendFetch(
`/api/v1/auth/saml/metadata/does-not-exist?project_id=${projectId}`,
{ method: "GET" },
);
expect(response.status).toBe(404);
});
it("returns 404 when the connection exists but has no IdP cert configured", async ({ expect }) => {
const { projectId } = await Project.createAndSwitch();
// Create a connection at branch level but skip the env-level IdP fields.
await Project.updateConfig({
"auth.saml.connections.partial.displayName": "Partial",
"auth.saml.connections.partial.allowSignIn": true,
// No idpEntityId / idpSsoUrl / idpCertificate.
});
const response = await niceBackendFetch(
`/api/v1/auth/saml/metadata/partial?project_id=${projectId}`,
{ method: "GET" },
);
// Without the IdP fields, the SP metadata wouldn't be useful (the IdP
// and SP need to know each other's cert for trust). Return 404.
expect(response.status).toBe(404);
});