Skip to content
Draft
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
7 changes: 7 additions & 0 deletions .env.integration
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
PUBLIC_SITE_URL=https://localhost:5173
PUBLIC_SITE_SHORT_URL=https://localhost:5173
# Browser & SSR API calls go through the Vite proxy (avoids self-signed cert in browser)
PUBLIC_BACKEND_API_URL=https://localhost:5173
PUBLIC_GATEWAY_CSP_WILDCARD=https://localhost:*
# Vite dev server proxies /1/* and /2/* to this target
VITE_API_PROXY_TARGET=https://localhost:5001
87 changes: 87 additions & 0 deletions .github/workflows/ci-integration.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
on:
push:
branches:
- develop
- master
pull_request:
branches:
- develop
types: [opened, reopened, synchronize]
workflow_dispatch:

name: ci-integration

jobs:
test:
if: |
github.event_name == 'push' ||
github.event_name == 'workflow_dispatch' ||
(
github.event_name == 'pull_request' &&
github.event.pull_request.head.repo.full_name == github.event.pull_request.base.repo.full_name
)
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read
packages: read

steps:
- name: Checkout
uses: actions/checkout@v6

- uses: pnpm/action-setup@v6
name: Install pnpm
with:
run_install: false

- name: Install Node.js
uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
cache: 'pnpm'

- name: Install dependencies
shell: bash
run: pnpm install --frozen-lockfile --strict-peer-dependencies

- name: Get Playwright version
id: playwright-version
shell: bash
run: echo "version=$(pnpm list @playwright/test --json | jq -r '.[0].devDependencies["@playwright/test"].version')" >> $GITHUB_OUTPUT

- name: Cache Playwright browsers
uses: actions/cache@v4
id: playwright-cache
with:
path: ~/.cache/ms-playwright
key: playwright-${{ steps.playwright-version.outputs.version }}

- name: Install Playwright browsers
if: steps.playwright-cache.outputs.cache-hit != 'true'
shell: bash
run: pnpx playwright install --with-deps chromium

- name: Install Playwright system deps
if: steps.playwright-cache.outputs.cache-hit == 'true'
shell: bash
run: pnpx playwright install-deps chromium

- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Run integration tests
shell: bash
run: pnpm test:integration

- name: Upload Playwright report
if: failure()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: playwright-report/
retention-days: 7
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,13 @@
<tr>
<td>master</td>
<td><a href="https://github.com/OpenShock/Frontend/actions/workflows/ci-build.yml"><img src="https://github.com/OpenShock/Frontend/actions/workflows/ci-build.yml/badge.svg?branch=master" alt="Build Status" /></a></td>
<td><a href="https://github.com/OpenShock/Frontend/actions/workflows/ci-integration.yml"><img src="https://github.com/OpenShock/Frontend/actions/workflows/ci-integration.yml/badge.svg?branch=master" alt="Integration Tests" /></a></td>
<td><a href="https://github.com/OpenShock/Frontend/actions/workflows/codeql.yml"><img src="https://github.com/OpenShock/Frontend/actions/workflows/codeql.yml/badge.svg?branch=master" alt="CodeQL Status" /></a></td>
</tr>
<tr>
<td>develop</td>
<td><a href="https://github.com/OpenShock/Frontend/actions/workflows/ci-build.yml"><img src="https://github.com/OpenShock/Frontend/actions/workflows/ci-build.yml/badge.svg?branch=develop" alt="Build Status" /></a></td>
<td><a href="https://github.com/OpenShock/Frontend/actions/workflows/ci-integration.yml"><img src="https://github.com/OpenShock/Frontend/actions/workflows/ci-integration.yml/badge.svg?branch=develop" alt="Integration Tests" /></a></td>
<td><a href="https://github.com/OpenShock/Frontend/actions/workflows/codeql.yml"><img src="https://github.com/OpenShock/Frontend/actions/workflows/codeql.yml/badge.svg?branch=develop" alt="CodeQL Status" /></a></td>
</tr>
</table>
Expand Down
64 changes: 64 additions & 0 deletions docker-compose.integration.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_DB: openshock
POSTGRES_USER: openshock
POSTGRES_PASSWORD: openshock
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U openshock -d openshock']
interval: 5s
timeout: 5s
retries: 10

redis:
image: redis/redis-stack-server:latest
healthcheck:
test: ['CMD', 'redis-cli', 'ping']
interval: 5s
timeout: 5s
retries: 10

mailpit:
image: axllent/mailpit:latest
ports:
- '8025:8025' # Web UI (optional, for local debugging)

api:
image: ghcr.io/openshock/api:develop
ports:
- '5001:443'
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
environment:
ASPNETCORE_ENVIRONMENT: Development
OPENSHOCK_DISABLE_RATE_LIMITING: '1'
OPENSHOCK__DB__CONN: Host=postgres;Port=5432;Database=openshock;Username=openshock;Password=openshock
OPENSHOCK__REDIS__HOST: redis
OPENSHOCK__FRONTEND__SHORTURL: https://localhost:5173
OPENSHOCK__FRONTEND__BASEURL: https://localhost:5173
OPENSHOCK__FRONTEND__COOKIEDOMAIN: localhost
OPENSHOCK__TURNSTILE__ENABLE: 'false'
OPENSHOCK__MAIL__TYPE: SMTP
OPENSHOCK__MAIL__SENDER__NAME: OpenShock Dev
OPENSHOCK__MAIL__SENDER__EMAIL: dev@openshock.dev
OPENSHOCK__MAIL__SMTP__HOST: mailpit
OPENSHOCK__MAIL__SMTP__PORT: '1025'
OPENSHOCK__MAIL__SMTP__USERNAME: dev
OPENSHOCK__MAIL__SMTP__PASSWORD: dev
OPENSHOCK__MAIL__SMTP__ENABLESSL: 'false'
OPENSHOCK__MAIL__SMTP__VERIFYCERTIFICATE: 'false'
OPENSHOCK__LCG__COUNTRYCODE: DE
healthcheck:
test:
[
'CMD-SHELL',
'wget -qO /dev/null --no-check-certificate https://localhost/ 2>/dev/null; [ $? -ne 4 ]',
]
interval: 10s
timeout: 5s
retries: 18
start_period: 30s
38 changes: 38 additions & 0 deletions e2e/e2e/lib/api-client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
// Minimal API client used only for test teardown (account deletion).
// Account creation happens through the browser UI in full E2E tests.
import { BACKEND_URL } from './env';

export type AuthCookies = string[];

async function readBody(res: Response): Promise<string> {
try {
return await res.text();
} catch {
return '';
}
}

function joinCookieHeader(cookies: AuthCookies): string {
return cookies.map((c) => c.split(';', 1)[0]).join('; ');
}

/** Delete the account that owns the given auth cookies. */
export async function deleteSelf(cookies: AuthCookies): Promise<void> {
const res = await fetch(`${BACKEND_URL}/1/account`, {
method: 'DELETE',
headers: { Cookie: joinCookieHeader(cookies) },
});
if (!res.ok && res.status !== 404) {
throw new Error(
`account-delete failed: ${res.status} ${res.statusText} — ${await readBody(res)}`
);
}
}

/** Log out (invalidates the session server-side). */
export async function logout(cookies: AuthCookies): Promise<void> {
await fetch(`${BACKEND_URL}/1/account/logout`, {
method: 'POST',
headers: { Cookie: joinCookieHeader(cookies) },
});
}
13 changes: 13 additions & 0 deletions e2e/e2e/lib/env.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
// Full E2E tests.
// Local dev: TEST_FRONTEND_URL=https://local.openshock.dev (pnpm dev)
// Staging: TEST_FRONTEND_URL=https://next.openshock.dev (no captcha enforcement)
export const FRONTEND_URL = process.env.TEST_FRONTEND_URL ?? 'https://next.openshock.dev';
export const BACKEND_URL = process.env.TEST_BACKEND_URL ?? 'https://api.openshock.dev';

// MailPit captures test emails and exposes an HTTP API for reading them.
// In local dev, MailPit is included in Dev/docker-compose.yml and listens on
// localhost:8025 (HTTP UI) and localhost:1025 (SMTP).
// Set TEST_MAILPIT_URL to enable email-verification tests; leave empty to skip them.
// Local default: http://localhost:8025
// CI/staging: set explicitly or leave empty to skip
export const MAILPIT_URL = process.env.TEST_MAILPIT_URL ?? '';
84 changes: 84 additions & 0 deletions e2e/e2e/lib/mailpit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
// MailPit API client for reading test emails.
// MailPit docs: https://mailpit.axllent.org/docs/api-v1/

export interface MailpitSummary {
ID: string;
Subject: string;
To: Array<{ Address: string; Name: string }>;
Date: string;
}

export interface MailpitMessage extends MailpitSummary {
HTML: string;
Text: string;
}

async function fetchJson<T>(url: string): Promise<T> {
const res = await fetch(url);
if (!res.ok) throw new Error(`MailPit request failed: ${res.status} ${res.statusText}`);
return res.json() as Promise<T>;
}

/** List the most recent messages addressed to `to`. Returns newest-first. */
async function listMessagesTo(mailpitUrl: string, to: string): Promise<MailpitSummary[]> {
const query = encodeURIComponent(`to:"${to}"`);
const data = await fetchJson<{ messages: MailpitSummary[] | null }>(
`${mailpitUrl}/api/v1/messages?query=${query}&limit=10`
);
return data.messages ?? [];
}

/** Fetch the full body of a message. */
async function getMessage(mailpitUrl: string, id: string): Promise<MailpitMessage> {
return fetchJson<MailpitMessage>(`${mailpitUrl}/api/v1/message/${id}`);
}

/** Delete a message (cleanup). */
export async function deleteMessage(mailpitUrl: string, id: string): Promise<void> {
await fetch(`${mailpitUrl}/api/v1/message/${id}`, { method: 'DELETE' });
}

const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));

/** Poll MailPit until an email to `to` arrives, then return its full content. */
export async function waitForEmailTo(
mailpitUrl: string,
to: string,
{ timeoutMs = 30_000, pollMs = 2_000 } = {}
): Promise<MailpitMessage> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const summaries = await listMessagesTo(mailpitUrl, to);
if (summaries.length > 0) {
return getMessage(mailpitUrl, summaries[0].ID);
}
await sleep(pollMs);
}
throw new Error(
`No email to "${to}" found in MailPit (${mailpitUrl}) within ${timeoutMs / 1000}s`
);
}

/**
* Extract the first URL from the email that matches `pattern` and rewrite its
* origin to `targetOrigin` so navigation works against the test frontend.
*/
export function extractAndRewriteLink(
msg: MailpitMessage,
pattern: RegExp,
targetOrigin: string
): string | null {
const body = msg.HTML || msg.Text;
const match = body.match(pattern);
if (!match) return null;
try {
const original = new URL(match[0].replace(/&amp;/g, '&'));
const target = new URL(targetOrigin);
original.protocol = target.protocol;
original.hostname = target.hostname;
original.port = target.port;
return original.toString();
} catch {
return null;
}
}
Loading
Loading