Skip to content

Commit

Permalink
feat: add dev login (#195)
Browse files Browse the repository at this point in the history
Co-authored-by: Jasper Herzberg <jhrzbrg@outlook.com>
  • Loading branch information
timonmasberg and JSPRH committed May 16, 2023
1 parent 4d76234 commit 5f551bd
Show file tree
Hide file tree
Showing 37 changed files with 708 additions and 174 deletions.
12 changes: 9 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ jobs:
IS_PRODUCTION: true
DEPLOYMENT_NAME: E2E Runner
API_URL: http://localhost:3000/
OAUTH_CONFIG: ${{ secrets.DEV_OAUTH_CONFIG }}
OAUTH_CONFIG: undefined
- name: Create API Environment File
run: |
envsubst < apps/api/src/.env.template > apps/api/src/.env
Expand All @@ -58,9 +58,15 @@ jobs:
- name: Start and prepare MongoDB for E2Es
run: ./tools/db/kordis-db.sh init e2edb
- name: Run E2Es
run: npm run e2e
run: npm run serve:all:prod & (npx wait-on tcp:3000 && npx wait-on http://localhost:4200 && npx nx e2e spa-e2e)
env:
TEST_USERS: ${{ secrets.E2E_TEST_USERS }}
E2E_BASE_URL: http://localhost:4200/
- uses: actions/upload-artifact@v3
if: failure()
with:
name: e2e-test-results
path: test-results/
if-no-files-found: ignore

- name: SonarCloud Scan
uses: sonarsource/sonarcloud-github-action@master
Expand Down
8 changes: 7 additions & 1 deletion .github/workflows/next-deployment.yml
Original file line number Diff line number Diff line change
Expand Up @@ -61,4 +61,10 @@ jobs:
run: npx nx e2e spa-e2e
env:
E2E_BASE_URL: ${{ needs.deployment.outputs.spaUrl }}
TEST_USERS: ${{ secrets.E2E_TEST_USERS }}
AADB2C_TEST_USERS: ${{ secrets.E2E_TEST_USERS }}
- uses: actions/upload-artifact@v3
if: failure()
with:
name: e2e-test-results
path: test-results/
if-no-files-found: ignore
14 changes: 14 additions & 0 deletions apps/api/dev-tokens.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
### Development Tokens

This file contains some development tokens that can be used to directly call the
API with pre-defined claims. The test users are equivalent to the users used in
[E2Es](../spa-e2e/README.md) and the test users registered in the development
application of our AAD.

| **Username** | **ID** (`oid`) | **First name** (`first_name`) | **Last name** (`last_name`) | **Emails** (`emails`) | Token |
| ------------ | ------------------------------------ | ----------------------------- | --------------------------- | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| testuser | c0cc4404-7907-4480-86d3-ba4bfc513c6d | Test | User | testuser@kordis-leitstelle.de | `eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJvaWQiOiIxMjM0IiwiZW1haWxzIjpbInRlc3R1c2VyQHRlc3QuY29tIl0sImdpdmVuX25hbWUiOiJUZXN0IiwiZmFtaWx5X25hbWUiOiJVc2VyIDEifQ.` |

The claims will be mapped to the
[AuthUser](../../libs/shared/auth/src/lib/auth-user.model.ts) Model in the
[AuthInterceptor](../../libs/api/auth/src/lib/interceptors/auth.interceptor.ts).
1 change: 0 additions & 1 deletion apps/spa-e2e/.env.template

This file was deleted.

18 changes: 18 additions & 0 deletions apps/spa-e2e/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Kordis E2E Tests

For End-to-End testing we use
[Playwright](https://playwright.dev/docs/api/class-playwright). You can run all
Tests with `npm run e2e`. By default, tests are run in headless mode, you can
adjust the [Playwright configuration](./playwright.config.ts) if needed for
local testing. Make sure that you serve the API and the SPA
`npm run serve:all:prod`. If you want to test against an Azure Active Directory
as OAuth Provider, you have to also specify `AADB2C_TEST_USERS` as env variable
with the test users username and password
`[['testusername', 'testpassword'], ...]` (check the
[auth setup](./src/auth.setup.ts) for more information). In this case, the SPA
environment also needs an OAuth configuration. If you leave it empty, it will
run with the Dev Login, which is the default for local dev workstations.

We have a set of test users. Each test can be executed in the context of a user
with the [`asUser(<username>)`](./src/test-users.ts) function. No need to
explicitly log in or out.
36 changes: 28 additions & 8 deletions apps/spa-e2e/src/auth.setup.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,38 @@
import { test as setup } from '@playwright/test';

import { LoginPo } from './page-objects/login.po';
import { getAuthStoragePath, testUserPasswords } from './test-users';
import { TestUsernames, getAuthStoragePath, testUsernames } from './test-users';

// Documentation: https://playwright.dev/docs/auth#multiple-signed-in-roles

setup('authenticate as testusers', async ({ browser }) => {
for (const [username, password] of testUserPasswords.entries()) {
const context = await browser.newContext();
const page = await context.newPage();
await new LoginPo(page).login(username, password);
await page.waitForURL('/protected');
/**
* If Active Directory B2C Users are set, we use them (e.g. in Next Deployment E2Es),
* otherwise we fall back to our preset users for the DevAuthModule that have the same claims and usernames.
*/
if (process.env.AADB2C_TEST_USERS) {
const testUserPasswords: ReadonlyMap<TestUsernames, string> = new Map(
JSON.parse(process.env.AADB2C_TEST_USERS),
);

await context.storageState({ path: getAuthStoragePath(username) });
await context.close();
for (const [username, password] of testUserPasswords.entries()) {
const context = await browser.newContext();
const page = await context.newPage();
await new LoginPo(page).loginWithB2C(username, password);
await page.waitForURL('/protected');

await context.storageState({ path: getAuthStoragePath(username) });
await context.close();
}
} else {
for (const username of testUsernames) {
const context = await browser.newContext();
const page = await context.newPage();
await new LoginPo(page).loginViaDevAuth(username);
await page.waitForURL('/protected');

await context.storageState({ path: getAuthStoragePath(username) });
await context.close();
}
}
});
24 changes: 2 additions & 22 deletions apps/spa-e2e/src/auth.spec.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { expect, test } from '@playwright/test';

import { LoginPo } from './page-objects/login.po';
import { getAuthStoragePath, testUserPasswords } from './test-users';
import { asUser } from './test-users';

test('should get redirected to auth as unauthenticated', async ({ page }) => {
await page.goto('/');
Expand All @@ -10,27 +9,8 @@ test('should get redirected to auth as unauthenticated', async ({ page }) => {
await expect(page).toHaveURL('/auth');
});

test('should not be able to access /protected as unauthenticated', async ({
page,
}) => {
await page.goto('/protected');
await page.waitForURL('/auth');

await expect(page).toHaveURL('/auth');
});

test('should be able to login with redirect to /protected', async ({
page,
}) => {
const loginPo = new LoginPo(page);
await loginPo.login('testuser', testUserPasswords.get('testuser'));

await page.waitForURL('/protected');
await expect(page).toHaveURL('/protected');
});

test.describe('as authenticated', () => {
test.use({ storageState: getAuthStoragePath('testuser') });
asUser('testuser');

test('should get initially redirected to /protected', async ({ page }) => {
await page.goto('/');
Expand Down
22 changes: 17 additions & 5 deletions apps/spa-e2e/src/page-objects/login.po.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,8 @@ export class LoginPo {
};
constructor(private readonly page: Page) {}

async login(username: string, password: string): Promise<void> {
await this.page.goto('/auth');

const loginBtn = await this.page.waitForSelector(this.selectors.loginBtn);
await loginBtn.click();
async loginWithB2C(username: string, password: string): Promise<void> {
await this.gotoLoginPage();

const usernameInput = await this.page.waitForSelector(
this.selectors.b2c.userIdInput,
Expand All @@ -33,4 +30,19 @@ export class LoginPo {
await passwordInput.type(password);
await b2cLoginBtn.click();
}

async loginViaDevAuth(username: string): Promise<void> {
await this.gotoLoginPage();
const testUserLoginBtn = await this.page.waitForSelector(
`button[data-username="${username}"]`,
);
await testUserLoginBtn.click();
}

private async gotoLoginPage(): Promise<void> {
await this.page.goto('/auth');

const loginBtn = await this.page.waitForSelector(this.selectors.loginBtn);
await loginBtn.click();
}
}
23 changes: 7 additions & 16 deletions apps/spa-e2e/src/test-users.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,12 @@
import { config as configEnv } from 'dotenv';
import * as path from 'path';
import { test } from '@playwright/test';

export type testUsernames = 'testuser';
export const testUsernames = ['testuser'] as const;
export type TestUsernames = (typeof testUsernames)[number];

if (!process.env.CI) {
configEnv({
path: path.resolve(__dirname, '../../.env'),
});
export function getAuthStoragePath(username: TestUsernames): string {
return `playwright/.auth/${username}.json`;
}

/*
You should take a close look at what test user runs what test, so you do not use any user that might execute test that have side effects on your test!
*/
export const testUserPasswords: ReadonlyMap<testUsernames, string> = new Map(
JSON.parse(process.env.TEST_USERS),
);

export function getAuthStoragePath(username: testUsernames): string {
return `playwright/.auth/${username}.json`;
export function asUser(username: TestUsernames): void {
test.use({ storageState: getAuthStoragePath(username) });
}
38 changes: 9 additions & 29 deletions apps/spa/src/app/app.module.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,9 @@
import { HttpClientModule } from '@angular/common/http';
import { NgModule, inject } from '@angular/core';
import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { Router, RouterModule, Routes } from '@angular/router';
import { switchMap } from 'rxjs';
import { RouterModule, Routes } from '@angular/router';

import {
AuthComponent,
AuthModule,
AuthService,
authGuard,
} from '@kordis/spa/auth';
import { AuthModule, DevAuthModule, authGuard } from '@kordis/spa/auth';

import { environment } from '../environments/environment';
import { AppComponent } from './app.component';
Expand All @@ -21,22 +15,6 @@ const routes: Routes = [
redirectTo: 'protected',
pathMatch: 'full',
},
{
path: 'auth',
component: AuthComponent,
canActivate: [
() => {
const auth = inject(AuthService);
const router = inject(Router);

return auth.isAuthenticated$.pipe(
switchMap(async (isAuthenticated) =>
isAuthenticated ? router.navigate(['/protected']) : true,
),
);
},
],
},
{
path: 'protected',
component: ProtectedComponent,
Expand All @@ -51,10 +29,12 @@ const routes: Routes = [
BrowserModule,
HttpClientModule,
RouterModule.forRoot(routes),
AuthModule.forRoot(
environment.oauth.config,
environment.oauth.discoveryDocumentUrl,
),
environment.oauth
? AuthModule.forRoot(
environment.oauth.config,
environment.oauth.discoveryDocumentUrl,
)
: DevAuthModule.forRoot(),
],
providers: [],
bootstrap: [AppComponent],
Expand Down
8 changes: 8 additions & 0 deletions apps/spa/src/environments/environment.model.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { AuthConfig } from 'angular-oauth2-oidc';

export type Environment = {
production: boolean;
apiUrl: string;
deploymentName: string;
oauth?: { discoveryDocumentUrl: string; config: AuthConfig };
};
21 changes: 4 additions & 17 deletions apps/spa/src/environments/environment.template
Original file line number Diff line number Diff line change
@@ -1,21 +1,8 @@
export const environment = {
import { Environment } from './environment.model';

export const environment: Environment = {
production: $IS_PRODUCTION,
deploymentName: '$DEPLOYMENT_NAME',
apiUrl: '$API_URL',
oauth: { // todo: replace this with /$/OAUTH_CONFIG before merge into main
config: {
redirectUri: window.origin + '/auth',
oidc: true,
responseType: 'code',
clientId: '6b5aa2b3-6237-44ba-8448-252052e73831',
issuer:
'https://kordisleitstelle.b2clogin.com/5b974891-a530-4e68-ac04-e26a18c3bd46/v2.0/',
tokenEndpoint:
'https://kordisleitstelle.b2clogin.com/kordisleitstelle.onmicrosoft.com/b2c_1_signin/oauth2/v2.0/token',
scope: 'openid offline_access 6b5aa2b3-6237-44ba-8448-252052e73831',
strictDiscoveryDocumentValidation: false,
},
discoveryDocumentUrl:
'https://kordisleitstelle.b2clogin.com/kordisleitstelle.onmicrosoft.com/B2C_1_SignIn/v2.0/.well-known/openid-configuration',
},
oauth: $OAUTH_CONFIG as any,
};
22 changes: 3 additions & 19 deletions apps/spa/src/environments/environment.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,7 @@
import { AuthConfig } from 'angular-oauth2-oidc';
import { Environment } from './environment.model';

export const environment = {
export const environment: Environment = {
production: false,
deploymentName: 'Dev Local',
apiUrl: 'https://localhost:3333',
oauth: {
config: {
redirectUri: window.origin + '/auth',
oidc: true,
responseType: 'code',
clientId: '6b5aa2b3-6237-44ba-8448-252052e73831',
issuer:
'https://kordisleitstelle.b2clogin.com/5b974891-a530-4e68-ac04-e26a18c3bd46/v2.0/',
tokenEndpoint:
'https://kordisleitstelle.b2clogin.com/kordisleitstelle.onmicrosoft.com/b2c_1_signin/oauth2/v2.0/token',
scope: 'openid offline_access 6b5aa2b3-6237-44ba-8448-252052e73831',
strictDiscoveryDocumentValidation: false,
} as AuthConfig,
discoveryDocumentUrl:
'https://kordisleitstelle.b2clogin.com/kordisleitstelle.onmicrosoft.com/B2C_1_SignIn/v2.0/.well-known/openid-configuration',
},
apiUrl: 'https://localhost:3000',
};
4 changes: 2 additions & 2 deletions libs/api/auth/src/lib/decorators/user.decorator.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { createMock } from '@golevelup/ts-jest';

import { KordisRequest } from '@kordis/api/shared';
import {
createContextForRequest,
createGqlContextForRequest,
createParamDecoratorFactory,
} from '@kordis/api/test-helpers';
import { AuthUser } from '@kordis/shared/auth';
Expand All @@ -20,7 +20,7 @@ describe('User Decorator', () => {
const req = createMock<KordisRequest>({
user,
});
const context = createContextForRequest(req);
const context = createGqlContextForRequest(req);
const factory = createParamDecoratorFactory(User);
const result = factory(null, context);
expect(result).toEqual(user);
Expand Down
14 changes: 10 additions & 4 deletions libs/api/auth/src/lib/interceptors/auth.interceptor.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { CallHandler, UnauthorizedException } from '@nestjs/common';
import { Observable, firstValueFrom, of } from 'rxjs';

import { KordisRequest } from '@kordis/api/shared';
import { createContextForRequest } from '@kordis/api/test-helpers';
import { createGqlContextForRequest } from '@kordis/api/test-helpers';
import { AuthUser } from '@kordis/shared/auth';

import { AuthUserExtractorStrategy } from '../auth-user-extractor-strategies/auth-user-extractor.strategy';
Expand Down Expand Up @@ -35,7 +35,7 @@ describe('AuthInterceptor', () => {
await expect(
firstValueFrom(
service.intercept(
createContextForRequest(createMock<KordisRequest>()),
createGqlContextForRequest(createMock<KordisRequest>()),
createMock<CallHandler>(),
),
),
Expand All @@ -56,10 +56,16 @@ describe('AuthInterceptor', () => {
},
});

const ctxMock = createContextForRequest(createMock<KordisRequest>());
const gqlCtx = createGqlContextForRequest(createMock<KordisRequest>());

await expect(
firstValueFrom(service.intercept(ctxMock, handler)),
firstValueFrom(service.intercept(gqlCtx, handler)),
).resolves.toBeTruthy();

const httpCtx = createGqlContextForRequest(createMock<KordisRequest>());

await expect(
firstValueFrom(service.intercept(httpCtx, handler)),
).resolves.toBeTruthy();
});
});
Loading

0 comments on commit 5f551bd

Please sign in to comment.