chore: fix login integration (#10318)

# Which Problems Are Solved

Login integration tests are not executed in the pipeline

# How the Problems Are Solved

The login integration tests are fixed and added as a pipeline workflow.
It  tests against the built login docker image.
On pipeline failures, developers are guided on how to fix them using a
dev container configured for this purpose.

# Additional Changes

- email domains are replaced by example.com. In case the tests were
accidentally run against a cloud instance, it wouldn't cause bounces.
- pnpm is upgraded, because the --filter argument doesn't work for the
install command on the old version.
- The login Dockerfile is optimized for docker image builds

# Additional Changes From Review for
https://github.com/zitadel/zitadel/pull/10305

These changes were requested from @peintnermax 

- The base dev container starts without any services besides the
database and the dev container itself
- CONTRIBUTING.md is restructured
- To reproduce pipeline checks, only the devcontainer CLI and Docker are
needed. This is described in the CONTRIBUTING.md
- The convenience npm script "generate" is added

# Additional Context

- Follow-up for PR https://github.com/zitadel/zitadel/pull/10305
- Base for https://github.com/zitadel/zitadel/issues/10277
This commit is contained in:
Elio Bischof
2025-08-05 17:59:30 +02:00
committed by GitHub
parent 30175041c1
commit e210d0a16a
52 changed files with 11019 additions and 1746 deletions

View File

@@ -1,5 +1,6 @@
NEXT_PUBLIC_BASE_PATH="/ui/v2/login"
ZITADEL_API_URL=http://mock-zitadel:22222
ZITADEL_API_URL=http://localhost:22222
ZITADEL_SERVICE_USER_TOKEN="yolo"
EMAIL_VERIFICATION=true
DEBUG=true
PORT=3001
NEXT_PUBLIC_BASE_PATH=/ui/v2/login

View File

@@ -2,6 +2,7 @@ custom-config.js
.env*.local
standalone
tsconfig.tsbuildinfo
cypress
.DS_Store
node_modules
@@ -11,6 +12,5 @@ node_modules
dist
dist-ssr
*.local
.env
.vscode
/blob-report/

View File

@@ -3,21 +3,21 @@ FROM node:20-alpine AS base
FROM base AS build
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable && COREPACK_ENABLE_DOWNLOAD_PROMPT=0 corepack prepare pnpm@9.1.2 --activate && \
apk update && apk add --no-cache && \
RUN corepack enable && COREPACK_ENABLE_DOWNLOAD_PROMPT=0 corepack prepare pnpm@10.13.1 --activate && \
apk update && \
rm -rf /var/cache/apk/*
WORKDIR /app
COPY pnpm-lock.yaml ./
RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store pnpm fetch --frozen-lockfile
COPY package.json ./
RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store pnpm install --frozen-lockfile --prod
RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store pnpm install --frozen-lockfile
COPY . .
RUN pnpm build:login:standalone
FROM scratch AS build-out
COPY --from=build /app/.next/standalone /
COPY --from=build /app/.next/static /.next/static
COPY --from=build /app/public /public
COPY public public
FROM base AS login-standalone
WORKDIR /runtime
@@ -25,12 +25,13 @@ RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 nextjs
# If /.env-file/.env is mounted into the container, its variables are made available to the server before it starts up.
RUN mkdir -p /.env-file && touch /.env-file/.env && chown -R nextjs:nodejs /.env-file
COPY ./scripts/ ./
COPY --chown=nextjs:nodejs ./scripts/ ./
COPY --chown=nextjs:nodejs --from=build-out / ./
USER nextjs
ENV HOSTNAME="0.0.0.0"
ENV PORT=3000
ENV HOSTNAME="0.0.0.0" \
NEXT_PUBLIC_BASE_PATH="/ui/v2/login" \
PORT=3000
# TODO: Check healthy, not ready
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD ["/bin/sh", "-c", "node ./healthcheck.js http://localhost:${PORT}/ui/v2/login/healthy"]
ENTRYPOINT ["./entrypoint.sh"]
CMD ["/bin/sh", "-c", "node /runtime/healthcheck.js http://localhost:${PORT}/ui/v2/login/healthy"]
ENTRYPOINT ["/runtime/entrypoint.sh"]

View File

@@ -8,7 +8,8 @@
!next.config.mjs
!next-env-vars.d.ts
!next-env.d.ts
!tailwind.config.js
!tailwind.config.mjs
!postcss.config.cjs
!tsconfig.json
!package.json
!pnpm-lock.yaml

View File

@@ -1,20 +0,0 @@
{
"name": "login-test-acceptance",
"private": true,
"scripts": {
"test:acceptance": "dotenv -e ../login/.env.test.local playwright",
"test:acceptance:setup": "cd ../.. && make login_test_acceptance_setup_env && NODE_ENV=test turbo run test:acceptance:setup:dev",
"test:acceptance:setup:dev": "cd ../.. && make login_test_acceptance_setup_dev",
"clean": "rm -rf .turbo node_modules"
},
"devDependencies": {
"@faker-js/faker": "^9.7.0",
"@otplib/core": "^12.0.0",
"@otplib/plugin-crypto": "^12.0.0",
"@otplib/plugin-thirty-two": "^12.0.0",
"@playwright/test": "^1.52.0",
"dotenv-cli": "^8.0.0",
"gaxios": "^7.1.0",
"typescript": "^5.8.3"
}
}

View File

@@ -2,11 +2,11 @@ import { defineConfig } from "cypress";
export default defineConfig({
reporter: "list",
video: true,
e2e: {
baseUrl: process.env.LOGIN_BASE_URL || "http://localhost:3001/ui/v2/login",
specPattern: "integration/**/*.cy.{js,jsx,ts,tsx}",
supportFile: "support/e2e.{js,jsx,ts,tsx}",
specPattern: "integration/integration/**/*.cy.{js,jsx,ts,tsx}",
supportFile: "integration/support/e2e.{js,jsx,ts,tsx}",
setupNodeEvents(on, config) {
// implement node event listeners here
},

View File

@@ -1,8 +1,10 @@
FROM bufbuild/buf:1.54.0 AS proto-files
RUN buf export https://github.com/envoyproxy/protoc-gen-validate.git --path validate --output /proto-files && \
buf export https://github.com/grpc-ecosystem/grpc-gateway.git --path protoc-gen-openapiv2 --output /proto-files && \
buf export https://github.com/googleapis/googleapis.git --path protos/zitadelgoogle/api/annotations.proto --path google/api/http.proto --path google/api/field_behavior.proto --output /proto-files && \
buf export https://github.com/zitadel/zitadel.git --path ./proto/zitadel --output /proto-files
FROM bufbuild/buf:1.54.0 AS dependencies
RUN buf export https://github.com/envoyproxy/protoc-gen-validate.git --path validate --output /proto && \
buf export https://github.com/grpc-ecosystem/grpc-gateway.git --path protoc-gen-openapiv2 --output /proto && \
buf export https://github.com/googleapis/googleapis.git --path google/api/annotations.proto --path google/api/http.proto --path google/api/field_behavior.proto --output /proto
FROM bufbuild/buf:1.54.0 AS zitadel-protos
RUN buf export https://github.com/zitadel/zitadel.git --path ./proto/zitadel --output /zitadel
FROM golang:1.20.5-alpine3.18 AS mock-zitadel
@@ -10,6 +12,7 @@ RUN go install github.com/eliobischof/grpc-mock/cmd/grpc-mock@01b09f60db1b501178
COPY mocked-services.cfg .
COPY initial-stubs initial-stubs
COPY --from=proto-files /proto-files/ ./
COPY --from=dependencies /proto/ ./
COPY --from=zitadel-protos /zitadel/ ./zitadel/
ENTRYPOINT [ "sh", "-c", "grpc-mock -v 1 -proto $(tr '\n' ',' < ./mocked-services.cfg) -stub-dir ./initial-stubs -mock-addr :22222" ]

View File

@@ -1,5 +0,0 @@
{
"name": "Using fixtures to represent data",
"email": "hello@cypress.io",
"body": "Fixtures are a great way to mock data for responses to routes"
}

View File

@@ -22,22 +22,22 @@ describe("verify invite", () => {
user: {
userId: "221394658884845598",
state: 1,
username: "john@zitadel.com",
loginNames: ["john@zitadel.com"],
preferredLoginName: "john@zitadel.com",
username: "john@example.com",
loginNames: ["john@example.com"],
preferredLoginName: "john@example.com",
human: {
userId: "221394658884845598",
state: 1,
username: "john@zitadel.com",
loginNames: ["john@zitadel.com"],
preferredLoginName: "john@zitadel.com",
username: "john@example.com",
loginNames: ["john@example.com"],
preferredLoginName: "john@example.com",
profile: {
givenName: "John",
familyName: "Doe",
avatarUrl: "https://zitadel.com/avatar.jpg",
avatarUrl: "https://example.com/avatar.jpg",
},
email: {
email: "john@zitadel.com",
email: "john@example.com",
isVerified: false,
},
},
@@ -68,7 +68,7 @@ describe("verify invite", () => {
factors: {
user: {
id: "221394658884845598",
loginName: "john@zitadel.com",
loginName: "john@example.com",
},
password: undefined,
webAuthN: undefined,
@@ -93,7 +93,7 @@ describe("verify invite", () => {
stub("zitadel.user.v2.UserService", "VerifyInviteCode");
cy.visit("/verify?userId=221394658884845598&code=abc&invite=true");
cy.url({ timeout: 10_000 }).should("include", Cypress.config().baseUrl + "/authenticator/set");
cy.url().should("include", Cypress.config().baseUrl + "/authenticator/set");
});
it("shows an error if invite code validation failed", () => {

View File

@@ -33,7 +33,7 @@ describe("login", () => {
factors: {
user: {
id: "221394658884845598",
loginName: "john@zitadel.com",
loginName: "john@example.com",
},
password: undefined,
webAuthN: undefined,
@@ -64,22 +64,22 @@ describe("login", () => {
{
userId: "221394658884845598",
state: 1,
username: "john@zitadel.com",
loginNames: ["john@zitadel.com"],
preferredLoginName: "john@zitadel.com",
username: "john@example.com",
loginNames: ["john@example.com"],
preferredLoginName: "john@example.com",
human: {
userId: "221394658884845598",
state: 1,
username: "john@zitadel.com",
loginNames: ["john@zitadel.com"],
preferredLoginName: "john@zitadel.com",
username: "john@example.com",
loginNames: ["john@example.com"],
preferredLoginName: "john@example.com",
profile: {
givenName: "John",
familyName: "Doe",
avatarUrl: "https://zitadel.com/avatar.jpg",
avatarUrl: "https://example.com/avatar.jpg",
},
email: {
email: "john@zitadel.com",
email: "john@example.com",
isVerified: true,
},
},
@@ -94,8 +94,8 @@ describe("login", () => {
});
});
it("should redirect a user with password authentication to /password", () => {
cy.visit("/loginname?loginName=john%40zitadel.com&submit=true");
cy.url({ timeout: 10_000 }).should("include", Cypress.config().baseUrl + "/password");
cy.visit("/loginname?loginName=john%40example.com&submit=true");
cy.url({ timeout: 5 * 60_000 }).should("include", Cypress.config().baseUrl + "/password");
});
describe("with passkey prompt", () => {
beforeEach(() => {
@@ -112,8 +112,8 @@ describe("login", () => {
});
});
// it("should prompt a user to setup passwordless authentication if passkey is allowed in the login settings", () => {
// cy.visit("/loginname?loginName=john%40zitadel.com&submit=true");
// cy.location("pathname", { timeout: 10_000 }).should("eq", "/password");
// cy.visit("/loginname?loginName=john%40example.com&submit=true");
// cy.location("pathname", { timeout: 5 * 60_000 }).should("eq", "/password");
// cy.get('input[type="password"]').focus().type("MyStrongPassword!1");
// cy.get('button[type="submit"]').click();
// cy.location("pathname", { timeout: 10_000 }).should(
@@ -134,22 +134,22 @@ describe("login", () => {
{
userId: "221394658884845598",
state: 1,
username: "john@zitadel.com",
loginNames: ["john@zitadel.com"],
preferredLoginName: "john@zitadel.com",
username: "john@example.com",
loginNames: ["john@example.com"],
preferredLoginName: "john@example.com",
human: {
userId: "221394658884845598",
state: 1,
username: "john@zitadel.com",
loginNames: ["john@zitadel.com"],
preferredLoginName: "john@zitadel.com",
username: "john@example.com",
loginNames: ["john@example.com"],
preferredLoginName: "john@example.com",
profile: {
givenName: "John",
familyName: "Doe",
avatarUrl: "https://zitadel.com/avatar.jpg",
avatarUrl: "https://example.com/avatar.jpg",
},
email: {
email: "john@zitadel.com",
email: "john@example.com",
isVerified: true,
},
},
@@ -165,8 +165,8 @@ describe("login", () => {
});
it("should redirect a user with passwordless authentication to /passkey", () => {
cy.visit("/loginname?loginName=john%40zitadel.com&submit=true");
cy.url({ timeout: 10_000 }).should("include", Cypress.config().baseUrl + "/passkey");
cy.visit("/loginname?loginName=john%40example.com&submit=true");
cy.url().should("include", Cypress.config().baseUrl + "/passkey");
});
});
});

View File

@@ -15,7 +15,7 @@ describe("register idps", () => {
cy.visit("/idp");
cy.get('button[e2e="google"]').click();
cy.origin(IDP_URL, { args: IDP_URL }, (url) => {
cy.location("href", { timeout: 10_000 }).should("eq", url);
cy.location("href").should("eq", url);
});
});
});

View File

@@ -48,7 +48,7 @@ describe("register", () => {
factors: {
user: {
id: "221394658884845598",
loginName: "john@zitadel.com",
loginName: "john@example.com",
},
password: undefined,
webAuthN: undefined,
@@ -64,10 +64,10 @@ describe("register", () => {
cy.visit("/register");
cy.get('input[data-testid="firstname-text-input"]').focus().type("John");
cy.get('input[data-testid="lastname-text-input"]').focus().type("Doe");
cy.get('input[data-testid="email-text-input"]').focus().type("john@zitadel.com");
cy.get('input[data-testid="email-text-input"]').focus().type("john@example.com");
cy.get('input[type="checkbox"][value="privacypolicy"]').check();
cy.get('input[type="checkbox"][value="tos"]').check();
cy.get('button[type="submit"]').click();
cy.url({ timeout: 10_000 }).should("include", Cypress.config().baseUrl + "/passkey/set");
cy.url().should("include", Cypress.config().baseUrl + "/passkey/set");
});
});

View File

@@ -24,22 +24,22 @@ describe("verify email", () => {
user: {
userId: "221394658884845598",
state: 1,
username: "john@zitadel.com",
loginNames: ["john@zitadel.com"],
preferredLoginName: "john@zitadel.com",
username: "john@example.com",
loginNames: ["john@example.com"],
preferredLoginName: "john@example.com",
human: {
userId: "221394658884845598",
state: 1,
username: "john@zitadel.com",
loginNames: ["john@zitadel.com"],
preferredLoginName: "john@zitadel.com",
username: "john@example.com",
loginNames: ["john@example.com"],
preferredLoginName: "john@example.com",
profile: {
givenName: "John",
familyName: "Doe",
avatarUrl: "https://zitadel.com/avatar.jpg",
avatarUrl: "https://example.com/avatar.jpg",
},
email: {
email: "john@zitadel.com",
email: "john@example.com",
isVerified: false, // email is not verified yet
},
},
@@ -70,7 +70,7 @@ describe("verify email", () => {
factors: {
user: {
id: "221394658884845598",
loginName: "john@zitadel.com",
loginName: "john@example.com",
},
password: undefined,
webAuthN: undefined,
@@ -90,6 +90,6 @@ describe("verify email", () => {
// TODO: Avoid uncaught exception in application
cy.once("uncaught:exception", () => false);
cy.visit("/verify?userId=221394658884845598&code=abc");
cy.contains("Could not verify email", { timeout: 10_000 });
cy.contains("Could not verify email");
});
});

View File

@@ -1,4 +1,4 @@
const url = Cypress.env("CORE_MOCK_STUBS_URL") || "http://mock-zitadel:22220/v1/stubs";
const url = Cypress.env("CORE_MOCK_STUBS_URL") || "http://localhost:22220/v1/stubs";
function removeStub(service: string, method: string) {
return cy.request({

View File

@@ -4,5 +4,5 @@
"lib": ["es5", "dom"],
"types": ["cypress", "node"]
},
"include": ["**/*.ts"]
"include": ["**/*.ts", "../cypress.config.ts"]
}

View File

@@ -27,6 +27,8 @@ declare namespace NodeJS {
/**
* Optional: custom request headers to be added to every request
* Split by comma, key value pairs separated by colon
* For example: to call the Zitadel API at an internal address, you can set:
* `CUSTOM_REQUEST_HEADERS=Host:http://zitadel-internal:8080`
*/
CUSTOM_REQUEST_HEADERS?: string;
}

View File

@@ -1,5 +1,5 @@
{
"packageManager": "pnpm@9.1.2+sha256.19c17528f9ca20bd442e4ca42f00f1b9808a9cb419383cd04ba32ef19322aba7",
"packageManager": "pnpm@10.13.1",
"name": "@zitadel/login",
"private": true,
"type": "module",
@@ -15,8 +15,7 @@
"test:unit": "vitest --run",
"lint-staged": "lint-staged",
"clean": "rm -rf .turbo && rm -rf node_modules && rm -rf .next",
"test:integration:login": "cypress run",
"test:integration:login:debug": "cypress open",
"test:integration:login": "wait-on --simultaneous 1 http://localhost:3001/ui/v2/login/verify?userId=221394658884845598&code=abc && cypress run",
"test:acceptance": "dotenv -e ../login/.env.test.local playwright",
"test:acceptance:setup": "cd ../.. && make login_test_acceptance_setup_env && NODE_ENV=test turbo run test:acceptance:setup:dev",
"test:acceptance:setup:dev": "cd ../.. && make login_test_acceptance_setup_dev"
@@ -54,6 +53,11 @@
"devDependencies": {
"@babel/eslint-parser": "^7.23.0",
"@bufbuild/buf": "^1.53.0",
"@faker-js/faker": "^9.7.0",
"@otplib/core": "^12.0.0",
"@otplib/plugin-crypto": "^12.0.0",
"@otplib/plugin-thirty-two": "^12.0.0",
"@playwright/test": "^1.52.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"@types/ms": "2.1.0",
@@ -67,34 +71,30 @@
"@vercel/git-hooks": "1.0.0",
"@vitejs/plugin-react": "^4.4.1",
"autoprefixer": "10.4.21",
"concurrently": "^9.1.2",
"cypress": "^14.5.2",
"dotenv-cli": "^8.0.0",
"env-cmd": "^10.0.0",
"eslint": "^8.57.0",
"eslint-config-next": "15.4.0-canary.86",
"eslint-config-prettier": "^9.1.0",
"gaxios": "^7.1.0",
"grpc-tools": "1.13.0",
"jsdom": "^26.1.0",
"lint-staged": "15.5.1",
"make-dir-cli": "4.0.0",
"nodemon": "^3.1.9",
"postcss": "8.5.3",
"prettier": "^3.2.5",
"prettier-plugin-organize-imports": "^3.2.0",
"prettier-plugin-tailwindcss": "0.6.11",
"sass": "^1.87.0",
"start-server-and-test": "^2.0.11",
"tailwindcss": "3.4.14",
"ts-proto": "^2.7.0",
"typescript": "^5.8.3",
"vite-tsconfig-paths": "^5.1.4",
"vitest": "^2.0.0",
"concurrently": "^9.1.2",
"cypress": "^14.5.2",
"dotenv-cli": "^8.0.0",
"env-cmd": "^10.0.0",
"nodemon": "^3.1.9",
"start-server-and-test": "^2.0.11",
"@faker-js/faker": "^9.7.0",
"@otplib/core": "^12.0.0",
"@otplib/plugin-crypto": "^12.0.0",
"@otplib/plugin-thirty-two": "^12.0.0",
"@playwright/test": "^1.52.0",
"gaxios": "^7.1.0"
"wait-on": "^7.2.0"
}
}
}

9151
apps/login/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,22 +1,57 @@
{
"extends": ["//"],
"extends": [
"//"
],
"tasks": {
"build": {
"outputs": ["dist/**", ".next/**", "!.next/cache/**"],
"dependsOn": ["@zitadel/client#build"]
"outputs": [
"dist/**",
".next/**",
"!.next/cache/**"
],
"dependsOn": [
"@zitadel/client#build"
]
},
"build:login:standalone": {
"outputs": ["dist/**", ".next/**", "!.next/cache/**"],
"dependsOn": ["@zitadel/client#build"]
"outputs": [
"dist/**",
".next/**",
"!.next/cache/**"
],
"dependsOn": [
"@zitadel/client#build"
]
},
"dev": {
"dependsOn": ["@zitadel/client#build"]
"persistent": true,
"cache": false,
"dependsOn": [
"@zitadel/client#build"
]
},
"test": {
"dependsOn": ["@zitadel/client#build"]
"dependsOn": [
"@zitadel/client#build"
]
},
"test:unit": {
"dependsOn": ["@zitadel/client#build"]
"dependsOn": [
"@zitadel/client#build"
]
},
"test:integration:login": {
"inputs": [
".next/**",
"!.next/cache/**",
"integration/integration/**",
"integration/support/**",
"cypress.config.ts"
],
"outputs": [
"cypress/videos/**",
"cypress/screenshots/**"
]
}
}
}
}