E2E Testing Infrastructure¶
Problem Statement¶
SyRF has no end-to-end tests. Unit and integration tests cover individual components (saga state transitions, service methods, concurrency handling), but nothing validates the full user journey: clicking a button in the Angular frontend through to data persisted in MongoDB.
Core system flows — search upload, data export, bulk study update, screening, annotation — are only verified manually. Regressions in cross-service wiring, auth integration, or message routing go undetected until someone manually tests or a user reports a bug.
Goals¶
- Validate core user journeys end-to-end: browser → Angular → .NET API → MassTransit → MongoDB
- Test the real authentication flow (OIDC) without depending on Auth0
- Test the real S3 upload → Lambda → RabbitMQ → saga flow without depending on AWS
- Run in CI with no external service dependencies — fully self-contained
- Two tiers: fast smoke suite for on-demand PR validation, comprehensive suite on merge to main
- Replace the incomplete
MockAuthProviderwith a proper mock OIDC server
Non-Goals¶
- Testing Auth0-specific features (social account linking, Management API)
- Testing Elasticsearch integration (search indexing)
- Testing SES email delivery
- Testing Quartz scheduled jobs (deferred to future iteration)
- Performance or load testing
- Visual regression testing
Architecture¶
┌──────────────────────────────────────────────────────────────────────┐
│ Playwright Test Runner │
│ │
│ ┌────────────┐ ┌──────────────────┐ ┌──────────────────────────┐ │
│ │ Tier 1 │ │ Tier 2 │ │ Fixtures │ │
│ │ @smoke │ │ @full │ │ - auth (storageState) │ │
│ │ ~3 min │ │ ~15 min │ │ - db (MongoDB client) │ │
│ │ label-gated│ │ merge to main │ │ - api (HTTP client) │ │
│ └────────────┘ └──────────────────┘ └──────────────────────────┘ │
└───────────────────────────┬──────────────────────────────────────────┘
│ HTTP (Chromium)
▼
┌─────────────────────┐
│ Angular Dev Server │ (ng serve, port 4200)
│ OidcAuthProvider (generic OIDC)
│ issuer → mock OIDC server
└──────────┬──────────┘
│
┌─────────────────┼─────────────────┐
▼ ▼ ▼
┌──────────────┐ ┌──────────────┐ ┌──────────────────┐
│ .NET API │ │ .NET PM │ │ Mock OIDC Server │
│ (dotnet run) │ │ (dotnet run) │ │ (Docker) │
│ port 5080 │ │ port 5081 │ │ port 9000 │
│ │ │ │ │ navikt/mock- │
│ JWT Authority│ │ JWT Authority│ │ oauth2-server │
│ → :9000 │ │ → :9000 │ └──────────────────┘
└──────┬───────┘ └──────┬───────┘
│ │
▼ ▼
┌──────────────────────────────┐ ┌────────────────────────────┐
│ MongoDB (Docker) │ │ RabbitMQ (Docker) │
│ port 27017 │ │ port 5672 │
│ DB: syrf_e2e │ │ MassTransit transport │
└──────────────────────────────┘ └─────────────┬──────────────┘
│
┌───────────────┴───────────────┐
│ LocalStack (Docker) │
│ S3 (port 4566) │
│ Lambda: s3-notifier │
│ S3 event → Lambda → RabbitMQ │
└────────────────────────────────┘
Key Principle¶
The Angular app and .NET services run production code. The only configuration differences from a real deployment are:
- OIDC authority URL → mock OIDC server instead of Auth0
- MongoDB connection → local Docker instead of Atlas
- S3/Lambda → LocalStack instead of AWS
- RabbitMQ → local Docker instead of in-cluster broker
- External services (SES, Elasticsearch, Elastic APM) → disabled
Infrastructure Components¶
1. Mock OIDC Server — navikt/mock-oauth2-server¶
Replaces Auth0 for E2E tests. Acts as a real OIDC provider:
- Serves
/.well-known/openid-configurationand JWKS endpoints - Built-in login page where Playwright selects test users and configures claims
- Issues real signed JWTs that .NET services validate via standard JWT Bearer middleware
- Pre-configured test users carry SyRF-specific claims (
https://claims.syrf.org.uk/user_id,syrf_groups)
How it integrates:
- Angular: The
@auth0/auth0-angularSDK is not a generic OIDC client — it hardcodes Auth0-specific URL paths (/oauth/token,/authorize) rather than using OIDC discovery. Pointing itsdomainat the mock server will not work. Instead, a newOidcAuthProviderimplementation of the existingIAuthProviderinterface will use a standard OIDC client library (e.g.,angular-auth-oidc-client) and be registered via theAUTH_PROVIDERinjection token when running in E2E mode. The@auth0/auth0-angularpackage remains for production. This is exactly what theIAuthProviderabstraction was designed for. - .NET: Standard
.AddJwtBearer()withAuthoritypointed at the mock server. Same JWT validation pipeline, same claims extraction, same[Authorize]policies. No backend code changes required. - Auth0-specific features (social account linking, Management API, silent logout) are guarded by feature-availability checks and will be no-ops when those features are unavailable from the mock OIDC server.
Angular environment injection: The app loads config from appConfig.default.json at runtime
(not compile-time environments). For E2E, the config is overridden via environment variable
substitution in env.template.generated.json, setting auth0CustomDomain and auth0ClientId
to the mock OIDC server coordinates. An authProvider config key selects between
Auth0AuthProvider (production) and OidcAuthProvider (E2E). The ng serve proxy config
routes API calls to the local .NET services.
Test users (seeded in both mock OIDC config and MongoDB pmInvestigator):
| User | Role | Purpose |
|---|---|---|
e2e-admin@test.com |
Administrator, SyrfAdmin | Full access for setup/admin tests |
e2e-reviewer@test.com |
Member | Screening and annotation tests |
e2e-user@test.com |
Member | Standard user journey tests |
2. MockAuthProvider Removal¶
The existing MockAuthProvider (frontend-only, hardcoded user, X-Dev-Api-Key header) is
incomplete — the backend has no handler for X-Dev-Api-Key (noted as a TODO in the interceptor
code). It will be removed and replaced by the mock OIDC server approach.
Files to remove:
- src/services/web/src/app/core/auth/mock-auth.provider.ts
- src/services/web/src/app/core/auth/mock-auth.provider.spec.ts
Configuration to remove (complete scope):
- mockAuth flag from appConfig.default.json, appConfig.default.generated.json,
env.template.generated.json
- mockAuth branching in auth-interceptor.service.ts — remove _handleMockAuth method,
X-Dev-Api-Key logic, DEV_API_KEY import
- mockAuth references in app-settings.interface.ts, mock-config.ts
- mockAuthMode property in nav.component.ts and template usage
- mockAuth guard in admin.effects.ts
- Auto-generated files referencing mockAuth:
- generated/feature-flags.constants.ts (FEATURE_FLAG_DEFAULTS, ENV_VAR_NAMES, JSON_PATHS)
- generated/feature-flags.types.ts (AppConfig, ExternalConfig interfaces)
- generated/feature-flags.parser.ts (mockAuth parsing)
- generated/feature-flags.selectors.ts (selectMockAuth selector)
- app-config.generated.ts, external-config.generated.ts, load-config.generated.ts
- Note: generated files are produced by pnpm run generate:flags — the generator
config must be updated, then files regenerated
- app-config.service.ts — remove parseMockAuth special-casing
- .chart/values.yaml for web — remove mockAuth feature flag
mockAuth guard replacement strategy for auth.effects.ts:
| Effect | Current guard | Replacement |
|---|---|---|
connectAccount$ |
!mockAuth |
!!config.auth0ManagementApiAudience |
disconnectAccount$ |
!mockAuth |
!!config.auth0ManagementApiAudience |
managementAccessToken$ |
!mockAuth |
!!config.auth0ManagementApiAudience |
signInActions$ |
mockAuth → EMPTY |
Remove guard — OidcAuthProvider.loginWithRedirect() handles this |
refreshTokenSilentlyRefreshed$ |
!mockAuth |
Remove guard — OidcAuthProvider.getAccessTokenSilently() handles this |
userRegistered$ |
!mockAuth |
Remove guard — OidcAuthProvider.loginWithRedirect() handles this |
silentSignOut$ |
!mockAuth |
!!config.auth0CustomDomain (hardcoded Auth0 logout URL) |
signInRecorded$ |
!mockAuth |
Remove guard — both providers emit user$ |
mockUserSignInRecorded$ |
mockAuth |
Remove entirely — signInRecorded$ covers both |
Hardcoded Auth0 URL in silentSignOut$: The effect contains a hardcoded
https://signin.syrf.org.uk/v2/logout?client_id=... URL. This should be made configurable
(read from auth0CustomDomain config) and guarded by !!config.auth0CustomDomain.
What replaces MockAuthProvider:
- For E2E tests: mock OIDC server (full OIDC flow, real JWT validation)
- For local dev without Auth0: developers run the mock OIDC server locally via
docker compose -f e2e/docker-compose.e2e.yml up mock-oidc
3. MongoDB — mongo:7¶
- Database:
syrf_e2e(isolated from dev/staging) - Seeded per test run with baseline data (test users in
pmInvestigator) - Test data uses per-test unique identifiers to avoid collisions during parallel execution
- Read-only reference data (user accounts) shared across tests
- Mutable data (projects, studies, annotations) created per test
4. RabbitMQ — rabbitmq:3-management¶
- Standard MassTransit transport configuration
- Management UI available on port 15672 for debugging
- Used by both .NET services and the Lambda (via LocalStack)
5. LocalStack — S3 + Lambda¶
Emulates the AWS S3 → Lambda flow with the real Lambda code.
Why LocalStack instead of MinIO + substitute:
The S3 notifier Lambda contains significant business logic that must be tested:
- Two distinct workflows based on uploadkind metadata (search upload vs bulk study update)
- Complex JSON deserialization of ScreeningImportSettings (includes Dictionary fields)
and BulkStudyUpdateJobInfo (nested record type)
- LibraryFileType enum parsing with validation
- 12 required metadata fields across both workflows with specific error messages
- Different MassTransit patterns: Publish<T>() for events vs SubmitJob<T>() for commands
A substitute that bypasses this logic would leave real bugs untested.
Setup flow:
1. E2E setup script builds Lambda package: dotnet lambda package
2. Deploys to LocalStack via AWS CLI:
aws --endpoint-url=http://localhost:4566 lambda create-function ...
3. Creates S3 bucket (syrfappuploads) with event notification → Lambda trigger
4. Lambda environment variables configured with RabbitMQ connection pointing to local Docker
Trade-off: LocalStack image is ~1.9 GB and takes 30-60s to start. This is acceptable because: - One-time cost per CI run, not per test - The Lambda's metadata extraction, routing, and deserialization are critical business logic - Maintaining a substitute creates divergence risk
6. .NET Services — Native dotnet run¶
- API on port 5080, PM on port 5081 (offset from default dev ports to avoid conflicts)
- Environment:
E2ETestwithappsettings.E2ETest.json: - MongoDB →
mongodb://localhost:27017/syrf_e2e - JWT Authority →
http://localhost:9000 - RabbitMQ →
amqp://guest:guest@localhost:5672 - S3 endpoint →
http://localhost:4566(LocalStack) - Elasticsearch, SES, Elastic APM, Sentry → disabled
7. Angular Dev Server — ng serve¶
- Port 4200 with proxy config routing
/api/*to .NET services - E2E runtime config selects
OidcAuthProvider(generic OIDC) instead ofAuth0AuthProvider OidcAuthProviderimplements the existingIAuthProviderinterface usingangular-auth-oidc-client, configured with the mock OIDC server's issuer URL- All downstream code (interceptors, effects, guards, ngrx state) runs identically —
they depend on
IAuthProvider, not on Auth0 directly
Test Tiers and CI Integration¶
Tier 1: Smoke Suite (@smoke)¶
Trigger: PR label run:e2e-smoke, or auto-trigger on paths touching saga/auth/export code
Duration target: ~3 minutes
Tests: - Login → land on dashboard - Create a project - Upload a systematic search → verify studies appear in project - Bulk study update (PDF path, Custom ID) → verify changes reflected - Export data → verify CSV downloads with correct content
Tier 2: Comprehensive Suite (@full)¶
Trigger: PR label run:e2e-full, merge to main, or workflow_dispatch
Duration target: ~15 minutes
Tests: - Everything in Tier 1 - Screening workflow (include/exclude studies across reviewers) - Annotation workflow (add annotations, verify persistence) - Multi-user scenarios (two users screening the same project) - Data integrity checks (export matches what was entered via screening/annotation/bulk update) - Error paths (invalid reference files, upload timeouts) - Bulk study update with all supported field types
CI Workflow¶
Label-triggered, same pattern as preview environments:
on:
pull_request:
types: [labeled, synchronize]
push:
branches: [main]
workflow_dispatch:
inputs:
suite:
type: choice
options: [smoke, full]
jobs:
e2e-smoke:
if: >
contains(github.event.pull_request.labels.*.name, 'run:e2e-smoke')
|| (github.event_name == 'workflow_dispatch' && inputs.suite == 'smoke')
e2e-full:
if: >
github.event_name == 'push'
|| contains(github.event.pull_request.labels.*.name, 'run:e2e-full')
|| (github.event_name == 'workflow_dispatch' && inputs.suite == 'full')
Infrastructure: Docker containers declared as GitHub Actions services: (health-checked
automatically). .NET services started via dotnet run in background with health check polling.
Angular started via Playwright's webServer config.
Label lifecycle: Label automatically removed after run completes. Re-add to re-trigger.
Artifacts on failure: - Playwright HTML report (14-day retention) - Traces on first retry - Screenshots on failure - Video retained on failure
Project Structure¶
e2e/ # Top-level, spans all services
├── package.json # Playwright, MongoDB driver, helpers
├── tsconfig.json
├── playwright.config.ts # Projects, webServer, tiers
├── docker-compose.e2e.yml # MongoDB, RabbitMQ, mock OIDC, LocalStack
├── scripts/
│ ├── setup-localstack.sh # Build + deploy Lambda to LocalStack
│ ├── seed-db.ts # Baseline MongoDB seeding
│ └── wait-for-services.ts # Health check polling
├── config/
│ ├── mock-oidc/ # navikt test users + client config
│ ├── angular-e2e-env.json # Angular environment overrides
│ └── appsettings.E2ETest.json # .NET service config
├── global-setup.ts # Start infra, seed DB, start services
├── global-teardown.ts # Cleanup
├── fixtures/
│ ├── auth.fixture.ts # Authenticated page contexts
│ ├── auth.setup.ts # Login via mock OIDC, save storageState
│ ├── db.fixture.ts # MongoDB client for seeding/assertions
│ └── api.fixture.ts # Direct API client for test setup
├── pages/ # Page Object Models
│ ├── dashboard.page.ts
│ ├── project.page.ts
│ ├── screening.page.ts
│ ├── annotation.page.ts
│ ├── data-export.page.ts
│ ├── search-upload.page.ts
│ └── bulk-study-update.page.ts
├── tests/
│ ├── smoke/ # @smoke tagged — Tier 1
│ │ ├── login.spec.ts
│ │ ├── create-project.spec.ts
│ │ ├── search-upload.spec.ts
│ │ ├── bulk-study-update.spec.ts
│ │ └── data-export.spec.ts
│ └── full/ # @full tagged — Tier 2
│ ├── screening-workflow.spec.ts
│ ├── annotation-workflow.spec.ts
│ ├── multi-user-screening.spec.ts
│ ├── data-integrity.spec.ts
│ └── error-paths.spec.ts
├── helpers/
│ ├── mongo-client.ts # CSUUID-aware MongoDB queries
│ ├── test-data-factory.ts # Generate unique test data per test
│ └── wait-for-saga.ts # Poll API for saga/job completion
└── playwright/
└── .auth/ # gitignored — storageState files
Test Patterns¶
Per-Test Data Isolation¶
Every test creates its own project/data with unique identifiers to avoid collisions during parallel execution:
test('can upload a systematic search @smoke', async ({ authenticatedPage, db }) => {
const projectName = `e2e-search-upload-${Date.now()}`;
// Create project via UI or API fixture
// Upload search file
// Assert studies appear
// Assert MongoDB state
});
Waiting for Async Operations¶
MassTransit saga processing is asynchronous. Tests poll for expected outcomes:
// Preferred: wait for UI to reflect the change
await expect(page.getByText('5 studies imported')).toBeVisible({ timeout: 30_000 });
// Alternative: poll API when UI doesn't have a visible indicator
await expect.poll(async () => {
const res = await apiClient.get(`/api/projects/${projectId}/searches/${searchId}`);
return (await res.json()).status;
}, { timeout: 30_000, intervals: [1000, 2000, 5000] }).toBe('completed');
Database Assertions¶
Verify backend state directly when UI assertions aren't sufficient:
// Custom fixture provides CSUUID-aware MongoDB client
const study = await db.collection('pmStudy').findOne({
SystematicSearchId: searchId,
});
expect(study.ScreeningInfo.ProjectAgreementThresholds).toBeDefined();
Multiple User Roles¶
Playwright setup project authenticates each test user and caches storageState:
// Multi-user test
test('two reviewers can screen the same study @full', async ({ browser }) => {
const reviewer1 = await browser.newContext({
storageState: 'playwright/.auth/reviewer1.json',
});
const reviewer2 = await browser.newContext({
storageState: 'playwright/.auth/reviewer2.json',
});
// Both navigate to same project, screen same study
// Verify agreement/disagreement logic
});
Angular Considerations¶
- Disable Angular animations in E2E mode to avoid timing issues with Material components
- Use
data-testidattributes for stable Playwright selectors where role/text locators are insufficient - Playwright's auto-waiting handles Angular zone stability automatically
Risks and Mitigations¶
| Risk | Mitigation |
|---|---|
| LocalStack S3→Lambda notifications unreliable | Pin LocalStack version; verify notification chain in global setup before tests run |
| Flaky tests from shared mutable data | Per-test data isolation with unique identifiers |
| Slow CI from 4 Docker containers + 2 .NET services | Tier 1 is fast (~3 min); Tier 2 is label/merge-gated |
@auth0/auth0-angular incompatible with mock OIDC |
New OidcAuthProvider using generic OIDC library behind existing IAuthProvider interface |
| LocalStack .NET 10 runtime issues | Fallback: build Lambda as container image, deploy to LocalStack as container Lambda |
| Port conflicts with local dev | E2E uses offset ports (5080/5081) distinct from dev ports (8080/8081) |
| LocalStack image size (~1.9 GB) | Cached in GitHub Actions; one-time pull per runner |
| LocalStack requires Docker socket mount | GitHub Actions ubuntu-latest supports this; document in setup guide |
| Mock OIDC port 9000 protocol ambiguity | Use HTTP on port 9000 (non-HTTPS port); avoids self-signed cert handling |
| .NET service health check not specified | Services expose /health endpoint; wait-for-services.ts polls with 2s interval, 60s timeout, fail-fast on process exit |
Future Iterations¶
- Quartz integration: Add SQL Server container, test background job scheduling flows
- Visual regression: Add screenshot comparison for key UI states
- Performance baseline: Measure response times for critical flows
- Accessibility testing: Integrate axe-core via Playwright
- Preview environment E2E: Run smoke suite against deployed PR preview environments