Skip to content
Merged
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
45 changes: 21 additions & 24 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,13 @@ jobs:
ci:
name: CI
runs-on: ubuntu-latest
# TIMEOUT EXCEPTION: Postgres startup, DB migration, embedding service model load, and 5 test projects with coverage require ~20 min.
timeout-minutes: 30
# TIMEOUT EXCEPTION: Postgres startup, DB migration, embedding service model load,
# full docker stack build (all APIs + dashboard), 5 test projects with coverage,
# and Playwright e2e suite require ~45 min.
timeout-minutes: 60
env:
DB_PASSWORD: changeme
# C# integration tests connect to the db-only stack on localhost
TEST_POSTGRES_CONNECTION: Host=localhost;Database=postgres;Username=postgres;Password=changeme
ICD10_TEST_CONNECTION_STRING: Host=localhost;Database=icd10;Username=postgres;Password=changeme
steps:
Expand All @@ -40,37 +43,22 @@ jobs:
- run: dotnet restore
- run: dotnet tool restore

- name: Start Postgres (pgvector) via docker compose
run: make db-up

- name: Migrate Postgres schemas
- name: Start Postgres and migrate schemas
run: make db-migrate

# `make lint` runs the full Release build, which triggers
# `dotnet DataProvider postgres` codegen against the live database,
# so it has to come after db-up + db-migrate. Still kept ahead of
# the embedding service steps to fail fast on warnings.
# `dotnet DataProvider postgres` codegen against the live database.
- name: Format check
run: make fmt CHECK=1

- name: Lint
run: make lint

- name: Start embedding service
run: |
cd ICD10/embedding-service
docker compose up -d --build
# Wait until /health responds 200 (model load can take ~60s)
for i in $(seq 1 60); do
if curl -sf http://localhost:8000/health > /dev/null; then
echo "Embedding service ready"
exit 0
fi
sleep 2
done
echo "Embedding service failed to become healthy"
docker compose logs
exit 1
- name: Install Playwright browsers
run: cd Dashboard/dashboard-ts && pnpm exec playwright install --with-deps chromium

- name: Start full stack for e2e (all APIs + embedding service + dashboard)
run: make start-stack

- name: Test
run: make test
Expand All @@ -82,6 +70,15 @@ jobs:
name: coverage-report
path: |
TestResults/**/coverage.*
Dashboard/dashboard-ts/coverage/**
retention-days: 7

- name: Upload Playwright report
uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report
path: Dashboard/dashboard-ts/playwright-report/
retention-days: 7

- name: Build
Expand Down
2 changes: 2 additions & 0 deletions Clinical/Clinical.Api/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -818,6 +818,8 @@ Func<NpgsqlConnection> getConn
)
);

app.MapGet("/health", () => Results.Ok(new { Status = "healthy", Service = "Clinical.Api" }));

app.Run();

static object BuildSyncRecordsResponse(
Expand Down
3 changes: 1 addition & 2 deletions Dashboard/dashboard-ts/e2e/appointment.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,8 +106,7 @@ test.describe('Appointment E2E Tests', () => {
await page.click('text=Appointments');
await page.waitForSelector(`text=${uniqueServiceType}`, { timeout: 10000 });

const editButton = await page.locator(`tr:has-text('${uniqueServiceType}') .btn-secondary`)
.first;
const editButton = page.locator(`tr:has-text('${uniqueServiceType}') .btn-secondary`).first();
expect(editButton).toBeTruthy();
await editButton.click();

Expand Down
10 changes: 6 additions & 4 deletions Dashboard/dashboard-ts/e2e/calendar.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,9 +152,11 @@ test.describe('Calendar E2E Tests', () => {
await todayCell.click();
await page.waitForSelector(`text=${uniqueServiceType}`, { timeout: 10000 });

const editButton = await page.locator(
`.calendar-appointment-item:has-text('${uniqueServiceType}') button:has-text('Edit')`,
).first;
const editButton = page
.locator(
`.calendar-appointment-item:has-text('${uniqueServiceType}') button:has-text('Edit')`,
)
.first();
expect(editButton).toBeTruthy();
await editButton.click();

Expand Down Expand Up @@ -184,7 +186,7 @@ test.describe('Calendar E2E Tests', () => {
const newMonthYear = await page.textContent('.text-lg.font-semibold');
expect(newMonthYear).not.toEqual(currentMonthYear);

const prevButton = headerControls.locator('button.btn-secondary').first;
const prevButton = headerControls.locator('button.btn-secondary').first();
await prevButton.click();
await page.waitForTimeout(300);
await prevButton.click();
Expand Down
62 changes: 12 additions & 50 deletions Dashboard/dashboard-ts/e2e/dashboard.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
DashboardUrl,
expect,
GatekeeperUrl,
generateTestToken,
Page,
SchedulingUrl,
test,
Expand Down Expand Up @@ -890,7 +891,7 @@ test.describe('Dashboard Core E2E Tests', () => {
await page.waitForSelector('.sidebar', { timeout: 20000 });

// User menu button should be visible in header
const userMenuButton = await page.locator("[data-testid='user-menu-button']").first;
const userMenuButton = page.locator("[data-testid='user-menu-button']").first();
expect(userMenuButton).toBeTruthy();

// Click the user menu button to open dropdown
Expand All @@ -900,7 +901,7 @@ test.describe('Dashboard Core E2E Tests', () => {
await page.waitForSelector("[data-testid='user-dropdown']", { timeout: 5000 });

// Sign out button should be visible in the dropdown
const signOutButton = await page.locator("[data-testid='logout-button']").first;
const signOutButton = page.locator("[data-testid='logout-button']").first();
expect(signOutButton).toBeTruthy();

const isVisible = await signOutButton.isVisible();
Expand Down Expand Up @@ -937,17 +938,20 @@ test.describe('Dashboard Core E2E Tests', () => {
await page.close();
});

test('Gatekeeper API logout revokes token', async ({ request }) => {
// Test 1: Without a Bearer token, should return 401 Unauthorized
const unauthResponse = await request.post(`${GatekeeperUrl}/auth/logout`, {
test('Gatekeeper API logout revokes token', async ({ playwright, request }) => {
const unauthenticatedRequest = await playwright.request.newContext();
const unauthResponse = await unauthenticatedRequest.post(`${GatekeeperUrl}/auth/logout`, {
headers: { 'Content-Type': 'application/json' },
data: {},
});
await unauthenticatedRequest.dispose();
expect(unauthResponse.status()).toBe(401);

// Test 2: With a valid Bearer token, should return 204 NoContent (logout succeeds)
// Note: The request fixture doesn't have auth by default, so we need to use a different approach
// or the API may allow it in dev mode
const authResponse = await request.post(`${GatekeeperUrl}/auth/logout`, {
headers: { 'Content-Type': 'application/json' },
data: {},
});
expect(authResponse.status()).toBe(204);
});

test('User menu displays user initials and name in dropdown', async ({ browser }) => {
Expand Down Expand Up @@ -1040,45 +1044,3 @@ test.describe('Dashboard Core E2E Tests', () => {
await page.close();
});
});

// Helper function for generating tokens
function generateTestToken(
userId: string = 'e2e-test-user',
displayName: string = 'E2E Test User',
email: string = 'e2etest@example.com',
): string {
const signingKey = Buffer.alloc(32, 0);

const base64UrlEncode = (input: string): string => {
return Buffer.from(input)
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
};

const computeHmacSignature = (header: string, payload: string, key: Buffer): string => {
const crypto = require('crypto');
const data = Buffer.from(`${header}.${payload}`);
const hmac = crypto.createHmac('sha256', key);
hmac.update(data);
return base64UrlEncode(hmac.digest().toString());
};

const header = base64UrlEncode(JSON.stringify({ alg: 'HS256', typ: 'JWT' }));

const expiration = Math.floor(Date.now() / 1000) + 3600;
const payload = base64UrlEncode(
JSON.stringify({
sub: userId,
name: displayName,
email,
jti: `${Date.now()}-${Math.random()}`,
exp: expiration,
roles: ['admin', 'user'],
}),
);

const signature = computeHmacSignature(header, payload, signingKey);
return `${header}.${payload}.${signature}`;
}
Loading
Loading