Skip to content

Commit b610ce7

Browse files
Merge branch 'release/0.4.0'
2 parents 0040e4c + 3d79d86 commit b610ce7

11 files changed

Lines changed: 195 additions & 120 deletions

config/environments.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"angularUrl": "http://localhost:4200",
44
"apiUrl": "https://localhost:44378",
55
"apiBaseUrl": "https://localhost:44378/api/v1",
6-
"identityServerUrl": "https://sts.skoruba.local",
6+
"identityServerUrl": "https://localhost:44310",
77
"timeout": 30000,
88
"description": "Local development environment"
99
},

config/test-config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
export const APP_URLS = {
1212
angular: 'http://localhost:4200',
1313
api: 'https://localhost:44378/api/v1',
14-
identityServer: 'https://sts.skoruba.local',
14+
identityServer: 'https://localhost:44310',
1515
} as const;
1616

1717
/**

diagnostic-screenshot.png

-380 Bytes
Loading

fixtures/auth.fixtures.ts

Lines changed: 72 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Page, APIRequestContext } from '@playwright/test';
22
import testUsers from '../config/test-users.json';
3+
import { APP_URLS } from '../config/test-config';
34

45
/**
56
* Authentication Fixtures
@@ -31,30 +32,75 @@ export async function loginAs(
3132
await page.goto('/');
3233
await page.waitForLoadState('networkidle');
3334

35+
// If we're already authenticated for some reason, log out first
36+
if (await isAuthenticated(page)) {
37+
// the logout helper already waits for navigation etc.
38+
await logout(page);
39+
await page.goto('/');
40+
await page.waitForLoadState('networkidle');
41+
}
42+
3443
// Clear any existing auth tokens to ensure clean state
3544
await clearAuthTokens(page);
3645

3746
// Reload page after clearing tokens to ensure Guest state
3847
await page.reload();
3948
await page.waitForLoadState('networkidle');
4049

41-
// Wait for page to fully render
50+
// Pause briefly to allow Angular to render guest UI
4251
await page.waitForTimeout(1000);
4352

44-
// Click user icon in upper right corner
45-
const userIcon = page.locator('button[aria-label="User menu"], button mat-icon:has-text("account_circle"), header button:has(mat-icon)').last();
46-
await userIcon.click();
53+
// Click user icon in upper right corner to open menu
54+
const userIcon = page.locator(
55+
'button[aria-label="User menu"], button mat-icon:has-text("account_circle"), header button:has(mat-icon)'
56+
).last();
57+
await userIcon.waitFor({ state: 'visible', timeout: 10000 });
58+
59+
// perform click; in some browsers the click may hang, so retry with force if needed
60+
try {
61+
await userIcon.click({ timeout: 5000 });
62+
} catch (err) {
63+
console.warn('userIcon.click() failed, retrying with force:', err);
64+
await userIcon.click({ force: true });
65+
}
66+
4767
await page.waitForTimeout(500);
4868

4969
// Click "Login" option from dropdown menu
50-
const loginOption = page.locator('button:has-text("Login"), a:has-text("Login"), [role="menuitem"]:has-text("Login")').first();
70+
const loginOption = page.locator(
71+
'button:has-text("Login"), a:has-text("Login"), [role="menuitem"]:has-text("Login")'
72+
).first();
73+
74+
// Make sure the login option actually exists; otherwise abort early
75+
const optionCount = await loginOption.count();
76+
if (optionCount === 0) {
77+
const currentUrl = page.url();
78+
throw new Error(
79+
`Unable to find Login option in user menu. Current url: ${currentUrl}`
80+
);
81+
}
5182

52-
// Wait for login option to be visible before clicking
83+
// Ensure it's clickable and visible before firing the click
84+
await loginOption.scrollIntoViewIfNeeded();
5385
await loginOption.waitFor({ state: 'visible', timeout: 5000 });
54-
await loginOption.click();
86+
await loginOption.click({ timeout: 5000 });
5587

56-
// Wait for redirect to IdentityServer login page
57-
await page.waitForURL(/sts\.skoruba\.local.*/, { timeout: 10000 });
88+
// After clicking we expect to leave the Angular app. The redirect may go to
89+
// the configured identity server or an in-app login page; wait for either.
90+
const idHost = new URL(APP_URLS.identityServer).host.replace(/\./g, '\\.');
91+
const idRegex = new RegExp(`${idHost}.*`);
92+
try {
93+
await Promise.race([
94+
page.waitForURL(idRegex, { timeout: 30000 }),
95+
page.waitForSelector('input[name="Username"]', { timeout: 30000 })
96+
]);
97+
} catch (err) {
98+
const currentUrl = page.url();
99+
throw new Error(
100+
`Timed out waiting for login redirect after clicking login (30s). ` +
101+
`Current url: ${currentUrl}`
102+
);
103+
}
58104

59105
// Fill in login credentials
60106
await page.fill('input[name="Username"]', username);
@@ -64,10 +110,13 @@ export async function loginAs(
64110
await page.click('button:has-text("Login")');
65111

66112
// Wait for OAuth callback redirect back to Angular app
67-
await page.waitForURL(/localhost:4200.*/, { timeout: 15000 });
113+
await page.waitForURL(/localhost:4200.*/, { timeout: 30000 });
68114

69115
// Wait for dashboard to load (indicating successful authentication)
70-
await page.waitForSelector('h1:has-text("Dashboard"), h2:has-text("Dashboard"), .matero-page-title', { timeout: 10000 });
116+
await page.waitForSelector(
117+
'h1:has-text("Dashboard"), h2:has-text("Dashboard"), .matero-page-title',
118+
{ timeout: 10000 }
119+
);
71120
}
72121

73122
/**
@@ -110,7 +159,7 @@ export async function getApiToken(
110159
username: string,
111160
password: string
112161
): Promise<string> {
113-
const tokenEndpoint = 'https://sts.skoruba.local/connect/token';
162+
const tokenEndpoint = `${APP_URLS.identityServer}/connect/token`;
114163

115164
const response = await request.post(tokenEndpoint, {
116165
form: {
@@ -172,10 +221,18 @@ export async function logout(page: Page): Promise<void> {
172221
const logoutOption = page.locator('button:has-text("Logout"), a:has-text("Logout"), [role="menuitem"]:has-text("Logout")').first();
173222
await logoutOption.click();
174223

175-
// Wait for redirect to IdentityServer logout screen
176-
await page.waitForURL(/sts\.skoruba\.local.*/, { timeout: 10000 });
224+
// Wait for redirect to logout page. Depending on environment this may go to
225+
// the configured identity server host or local /Account/Logout.
226+
const idHost = new URL(APP_URLS.identityServer).host.replace(/\./g, '\\.');
227+
const logoutRegex = new RegExp(`(${idHost}.*|localhost\/Account\/Logout.*)`);
228+
try {
229+
await page.waitForURL(logoutRegex, { timeout: 15000 });
230+
} catch (err) {
231+
const currentUrl = page.url();
232+
console.warn(`logout(): expected external logout page but still at ${currentUrl}`);
233+
}
177234

178-
// Wait a moment for STS logout screen to load
235+
// Wait a moment for logout screen to render
179236
await page.waitForTimeout(1000);
180237

181238
// Look for the "click here" link to return to Angular

tests/api/auth-api.spec.ts

Lines changed: 38 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { test, expect } from '@playwright/test';
2-
import { getApiToken, getTokenForRole, loginAsRole, getTokenFromProfile, logout } from '../../fixtures/auth.fixtures';
2+
import { getApiToken, getTokenForRole, loginAsRole, getTokenFromProfile, logout, clearAuthTokens } from '../../fixtures/auth.fixtures';
3+
import { APP_URLS } from '../../config/test-config';
34

45
/**
56
* Authentication API Tests
@@ -19,8 +20,8 @@ import { getApiToken, getTokenForRole, loginAsRole, getTokenFromProfile, logout
1920
let authFailed = false;
2021

2122
test.describe('Authentication API', () => {
22-
const identityServerUrl = 'https://sts.skoruba.local';
23-
const baseURL = 'https://localhost:44378/api/v1';
23+
const identityServerUrl = APP_URLS.identityServer;
24+
const baseURL = APP_URLS.api;
2425

2526
test.beforeEach(async ({ page }) => {
2627
// Try to detect if IdentityServer is available
@@ -118,19 +119,33 @@ test.describe('Authentication API', () => {
118119
test('should reject request with invalid credentials', async ({ page }) => {
119120
if (authFailed) test.skip();
120121

121-
// Try to login with invalid credentials
122+
// ensure clean state
123+
await page.goto('/');
124+
await clearAuthTokens(page);
125+
await logout(page).catch(() => {});
122126
await page.goto('/');
123-
await page.waitForLoadState('networkidle');
124127

125-
const userIcon = page.locator('button mat-icon:has-text("account_circle")').last();
128+
// open user menu safely
129+
const userIcon = page.locator(
130+
'button[aria-label="User menu"], button mat-icon:has-text("account_circle"), header button:has(mat-icon)'
131+
).last();
132+
await userIcon.waitFor({ state: 'visible', timeout: 10000 });
126133
await userIcon.click();
127134
await page.waitForTimeout(500);
128135

129-
const loginOption = page.locator('[role="menuitem"]:has-text("Login")').first();
136+
const loginOption = page.locator(
137+
'button:has-text("Login"), a:has-text("Login"), [role="menuitem"]:has-text("Login")'
138+
).first();
139+
const optCount = await loginOption.count();
140+
expect(optCount).toBeGreaterThan(0);
141+
await loginOption.waitFor({ state: 'visible', timeout: 5000 });
130142
await loginOption.click();
131143

132-
// Should redirect to IdentityServer
133-
await page.waitForURL(/sts\.skoruba\.local.*/, { timeout: 10000 });
144+
// Wait for some indication that we're on the login page (STS or form)
145+
await Promise.race([
146+
page.waitForURL(/sts\.skoruba\.local.*/, { timeout: 15000 }),
147+
page.waitForSelector('input[name="Username"]', { timeout: 15000 })
148+
]);
134149

135150
// Dismiss cookie consent if it appears
136151
const cookieButton = page.locator('button:has-text("Got it")');
@@ -139,23 +154,27 @@ test.describe('Authentication API', () => {
139154
await cookieButton.click();
140155
}
141156

142-
// Enter invalid credentials
157+
// Enter invalid credentials and submit
143158
await page.locator('input[name="Username"]').fill('invaliduser');
144159
await page.locator('input[name="Password"]').fill('wrongpassword');
145-
146-
// Click Login button (not submit, as it's a regular button)
147160
await page.locator('button:has-text("Login")').click();
148161

149-
// Wait for response
162+
// Wait shortly for potential navigation
150163
await page.waitForTimeout(3000);
151164

152-
// Should still be on login page (not redirected back to app)
153-
// This proves authentication failed
165+
// Should still be on some form page (not navigated back to Angular)
154166
const currentUrl = page.url();
155-
expect(currentUrl).toContain('sts.skoruba.local');
156-
157-
// Should NOT be on the dashboard (successful login redirects to dashboard)
167+
// Allow either the STS host or the local account login page
168+
const idHost = new URL(APP_URLS.identityServer).host;
169+
const validPrefixes = [idHost, '/Account/Login'];
170+
expect(validPrefixes.some(prefix => currentUrl.includes(prefix))).toBe(true);
158171
expect(currentUrl).not.toContain('dashboard');
172+
173+
// Optionally verify error message appears
174+
const errorBanner = page.locator('text=/invalid|error|failed/i').first();
175+
if (await errorBanner.count() > 0) {
176+
await expect(errorBanner).toBeVisible();
177+
}
159178
});
160179

161180
test('should include proper claims in token', async ({ page }) => {
@@ -361,7 +380,7 @@ test.describe('Authentication API', () => {
361380

362381
// Check for issuer claim (should be IdentityServer URL)
363382
expect(payload.iss).toBeDefined();
364-
expect(payload.iss).toContain('sts.skoruba.local');
383+
expect(payload.iss).toContain(new URL(APP_URLS.identityServer).host);
365384
});
366385
});
367386

tests/auth/login.spec.ts

Lines changed: 46 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { test, expect } from '@playwright/test';
2-
import { loginAs, loginAsRole, isAuthenticated, getStoredToken } from '../../fixtures/auth.fixtures';
2+
import { loginAs, loginAsRole, isAuthenticated, getStoredToken, clearAuthTokens, logout } from '../../fixtures/auth.fixtures';
33

44
/**
55
* Authentication Tests - Login Flow
@@ -13,7 +13,12 @@ import { loginAs, loginAsRole, isAuthenticated, getStoredToken } from '../../fix
1313

1414
test.describe('Login Flow', () => {
1515
test.beforeEach(async ({ page }) => {
16-
// Start from the Angular app
16+
// ensure we start in a clean, anonymous state
17+
await page.goto('/');
18+
// clear any tokens that might persist between tests
19+
await clearAuthTokens(page);
20+
// if somehow still authenticated, log out (ignore errors)
21+
await logout(page).catch(() => {});
1722
await page.goto('/');
1823
});
1924

@@ -22,18 +27,30 @@ test.describe('Login Flow', () => {
2227
await page.goto('/');
2328
await page.waitForLoadState('networkidle');
2429

25-
// Click user menu and then Login
26-
const userIcon = page.locator('button[aria-label="User menu"], button mat-icon:has-text("account_circle"), header button:has(mat-icon)').last();
30+
// open user menu (reuse same selector logic as helper)
31+
const userIcon = page.locator(
32+
'button[aria-label="User menu"], button mat-icon:has-text("account_circle"), header button:has(mat-icon)'
33+
).last();
34+
await userIcon.waitFor({ state: 'visible', timeout: 10000 });
2735
await userIcon.click();
2836
await page.waitForTimeout(500);
2937

30-
const loginOption = page.locator('button:has-text("Login"), a:has-text("Login"), [role="menuitem"]:has-text("Login")').first();
38+
// ensure login option is present before clicking
39+
const loginOption = page.locator(
40+
'button:has-text("Login"), a:has-text("Login"), [role="menuitem"]:has-text("Login")'
41+
).first();
42+
const count = await loginOption.count();
43+
expect(count).toBeGreaterThan(0);
44+
await loginOption.waitFor({ state: 'visible', timeout: 5000 });
3145
await loginOption.click();
3246

33-
// Should redirect to IdentityServer
34-
await page.waitForURL(/sts\.skoruba\.local.*/, { timeout: 10000 });
47+
// Should redirect to IdentityServer or show login form
48+
await Promise.race([
49+
page.waitForURL(/sts\.skoruba\.local.*/, { timeout: 15000 }),
50+
page.waitForSelector('input[name="Username"]', { timeout: 15000 })
51+
]);
3552

36-
// Verify IdentityServer login page elements
53+
// Verify IdentityServer login page elements (or equivalent form)
3754
await expect(page.locator('input[name="Username"]')).toBeVisible();
3855
await expect(page.locator('input[name="Password"]')).toBeVisible();
3956
await expect(page.locator('button:has-text("Login")')).toBeVisible();
@@ -126,33 +143,45 @@ test.describe('Login Flow', () => {
126143
});
127144

128145
test('should show error message with invalid credentials', async ({ page }) => {
146+
// ensure a clean guest session
147+
await page.goto('/');
148+
await clearAuthTokens(page);
149+
await logout(page).catch(() => {});
129150
await page.goto('/');
130-
await page.waitForLoadState('networkidle');
131151

132-
// Click user menu and then Login
152+
// Click user menu and then Login (robust selectors)
133153
const userIcon = page.locator('button[aria-label="User menu"], button mat-icon:has-text("account_circle"), header button:has(mat-icon)').last();
154+
await userIcon.waitFor({ state: 'visible', timeout: 10000 });
134155
await userIcon.click();
135156
await page.waitForTimeout(500);
136157

137158
const loginOption = page.locator('button:has-text("Login"), a:has-text("Login"), [role="menuitem"]:has-text("Login")').first();
159+
const count = await loginOption.count();
160+
expect(count).toBeGreaterThan(0);
138161
await loginOption.click();
139162

140-
// Wait for IdentityServer login page
141-
await page.waitForURL(/sts\.skoruba\.local.*/);
163+
// Wait for login form to appear (either STS host or local login page)
164+
await Promise.race([
165+
page.waitForURL(/sts\.skoruba\.local.*/, { timeout: 15000 }),
166+
page.waitForURL(/localhost\/Account\/Login.*/, { timeout: 15000 }),
167+
page.waitForSelector('input[name="Username"]', { timeout: 15000 })
168+
]);
142169

143170
// Try to login with invalid credentials
144171
await page.fill('input[name="Username"]', 'invalid_user');
145172
await page.fill('input[name="Password"]', 'wrong_password');
146173
await page.click('button:has-text("Login")');
147174

148-
// Wait a moment for error to appear
175+
// Wait a moment for error or navigation
149176
await page.waitForTimeout(2000);
150177

151-
// Should still be on IdentityServer page (didn't redirect back)
152-
expect(page.url()).toContain('sts.skoruba.local');
178+
// Should still be on a login page (not redirected back to app)
179+
const currentUrl = page.url();
180+
expect(currentUrl).toMatch(/sts\.skoruba\.local|\/Account\/Login/);
181+
expect(currentUrl).not.toContain('dashboard');
153182

154-
// Error message might be displayed (implementation varies)
183+
// Error message should be visible when credentials are invalid
155184
const errorMessage = page.locator('text=/invalid.*username.*password|Invalid credentials/i');
156-
await errorMessage.isVisible({ timeout: 3000 }).catch(() => false);
185+
await expect(errorMessage.first()).toBeVisible({ timeout: 5000 });
157186
});
158187
});

tests/diagnostic.spec.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { test, expect } from '@playwright/test';
2+
import { APP_URLS } from '../config/test-config';
23

34
test('Diagnostic: Check Angular app behavior', async ({ page }) => {
45
console.log('\n=== DIAGNOSTIC TEST ===');
@@ -11,7 +12,8 @@ test('Diagnostic: Check Angular app behavior', async ({ page }) => {
1112
console.log('Current URL:', currentUrl);
1213

1314
// Check if we're on IdentityServer or Angular
14-
if (currentUrl.includes('sts.skoruba.local')) {
15+
const idHost = new URL(APP_URLS.identityServer).host;
16+
if (currentUrl.includes(idHost)) {
1517
console.log('✅ Redirected to IdentityServer (AUTH ENABLED)');
1618
} else if (currentUrl.includes('localhost:4200')) {
1719
console.log('❌ Stayed on Angular app (AUTH DISABLED)');

0 commit comments

Comments
 (0)