Skip to content
Draft
Show file tree
Hide file tree
Changes from 5 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
60 changes: 60 additions & 0 deletions docker-compose.integration.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
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