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
21 changes: 21 additions & 0 deletions src/Turnierplan.App.Test.Functional/Routes.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
using Turnierplan.Core.PublicId;

namespace Turnierplan.App.Test.Functional;

internal static class Routes
{
public static class ApiKeys
{
public static string Delete(PublicId id) => $"/api/api-keys/{id}";
}

public static class Identity
{
public static string Login() => "/api/identity/login";
}

public static class Users
{
public static string Delete(Guid id) => $"/api/users/{id}";
}
}
58 changes: 58 additions & 0 deletions src/Turnierplan.App.Test.Functional/Scenarios.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
using FluentAssertions;
using FluentAssertions.Extensions;
using Turnierplan.Core.ApiKey;
using Turnierplan.Core.Extensions;
using Turnierplan.Core.Organization;
using Turnierplan.Core.RoleAssignment;
using Turnierplan.Core.Tournament;
using Turnierplan.Core.User;
using Xunit;

namespace Turnierplan.App.Test.Functional;

public sealed class Scenarios
{
private readonly TestServer _testServer = new();

[Fact]
public async Task When_ApiKey_And_User_Are_Deleted_The_Role_Assignments_Are_Also_Removed()
{
var (apiKeyId, userId) = _testServer.ExecuteContextAction(db =>
{
var user = new User(string.Empty);
db.Users.Add(user);

var org = new Organization(string.Empty);
db.Organizations.Add(org);

var key = new ApiKey(org, string.Empty, null, DateTime.UtcNow + 1.Days());
db.ApiKeys.Add(key);

var tournament = new Tournament(org, string.Empty, Visibility.Public);
db.Tournaments.Add(tournament);

org.AddRoleAssignment(Role.Reader, user.AsPrincipal());
tournament.AddRoleAssignment(Role.Contributor, user.AsPrincipal());
tournament.AddRoleAssignment(Role.Reader, key.AsPrincipal());

db.SaveChanges();

return (key.PublicId, user.Id);
});

_testServer.ExecuteContextAction(db => db.OrganizationRoleAssignments.Count()).Should().Be(1);
_testServer.ExecuteContextAction(db => db.TournamentRoleAssignments.Count()).Should().Be(2);

var resp = await _testServer.Client.DeleteAsync(Routes.ApiKeys.Delete(apiKeyId), TestContext.Current.CancellationToken);
resp.EnsureSuccessStatusCode();

_testServer.ExecuteContextAction(db => db.OrganizationRoleAssignments.Count()).Should().Be(1);
_testServer.ExecuteContextAction(db => db.TournamentRoleAssignments.Count()).Should().Be(1);

resp = await _testServer.Client.DeleteAsync(Routes.Users.Delete(userId), TestContext.Current.CancellationToken);
resp.EnsureSuccessStatusCode();

_testServer.ExecuteContextAction(db => db.OrganizationRoleAssignments.Count()).Should().Be(0);
_testServer.ExecuteContextAction(db => db.TournamentRoleAssignments.Count()).Should().Be(0);
}
}
72 changes: 72 additions & 0 deletions src/Turnierplan.App.Test.Functional/TestServer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
using System.Net.Http.Json;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Turnierplan.Core.User;
using Turnierplan.Dal;

namespace Turnierplan.App.Test.Functional;

internal sealed class TestServer
{
private readonly WebApplicationFactory<Program> _application;

public TestServer()
{
_application = new WebApplicationFactory<Program>().WithWebHostBuilder(builder =>
{
builder.ConfigureAppConfiguration(config =>
{
config.AddInMemoryCollection([
new KeyValuePair<string, string?>("Database:InMemory", "true"),
new KeyValuePair<string, string?>("Identity:UseInsecureCookies", "true")
]);
});
});

const string username = "functional_test";
const string password = "P@ssw0rd";

using (var scope = _application.Services.CreateScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<TurnierplanContext>();

var user = new User(username)
{
IsAdministrator = true
};

user.UpdatePassword(scope.ServiceProvider.GetRequiredService<IPasswordHasher<User>>().HashPassword(user, password));

ctx.Users.Add(user);
ctx.SaveChanges();
}

var loginRequest = new HttpRequestMessage(HttpMethod.Post, Routes.Identity.Login())
{
Content = JsonContent.Create(new { UserName = username, Password = password})
};

Client = _application.CreateClient(new WebApplicationFactoryClientOptions { HandleCookies = true });
var loginResponseTask = Client.SendAsync(loginRequest);
loginResponseTask.Wait();
var loginResponse = loginResponseTask.Result;
loginResponse.EnsureSuccessStatusCode();
}

public HttpClient Client { get; }

public void ExecuteContextAction(Action<TurnierplanContext> action)
{
using var scope = _application.Services.CreateScope();
action(scope.ServiceProvider.GetRequiredService<TurnierplanContext>());
}

public T ExecuteContextAction<T>(Func<TurnierplanContext, T> action)
{
using var scope = _application.Services.CreateScope();
return action(scope.ServiceProvider.GetRequiredService<TurnierplanContext>());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<Import Project="../version.xml" />

<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>

<Import Project="../Turnierplan.Test.Common.props" />

<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.2" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\Turnierplan.App\Turnierplan.App.csproj" />
</ItemGroup>
</Project>
19 changes: 18 additions & 1 deletion src/Turnierplan.App/Client/e2e/consts/turnierplan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,15 +26,32 @@ export const turnierplan = {
navigationTab: (id: number) => `page-frame-navigation-tab-${id}`,
title: 'page-frame-title'
},
rbacWidget: {
openOffcanvasButton: 'rbac-widget-open-offcanvas-button'
},
rbacOffcanvas: {
doneButton: 'rbac-offcanvas-done-button',
assignmentsCount: 'rbac-offcanvas-assignments-count'
},
viewOrganizationPage: {
tournamentsPageId: 0,
apiKeysPageId: 2,
settingsPageId: 3,
newTournamentButton: 'view-organization-page-new-tournament-button'
newTournamentButton: 'view-organization-page-new-tournament-button',
newApiKeyButton: 'view-organization-page-new-api-key-button',
deleteApiKeyButton: (id: string) => `view-organization-page-delete-api-key-button-${id}`
},
createTournamentPage: {
confirmButton: 'create-tournament-page-confirm-button',
tournamentNameField: 'create-tournament-page-tournament-name-field'
},
createApiKeyPage: {
apiKeyNameField: 'create-api-key-page-api-key-name-field',
confirmButton: 'create-api-key-page-confirm-button',
resultIdField: 'create-api-key-page-result-id-field',
resultSecretField: 'create-api-key-page-result-secret-field',
doneButton: 'create-api-key-page-done-button'
},
configureTournamentPage: {
addGroupButton: 'configure-tournament-page-add-group-button',
shuffleGroupsButton: 'configure-tournament-page-shuffle-groups-button',
Expand Down
14 changes: 14 additions & 0 deletions src/Turnierplan.App/Client/e2e/pages/rbac-offcanvas.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { Locator, Page } from '@playwright/test';
import { turnierplan } from '../consts/turnierplan';

export class RbacOffcanvas {
constructor(private readonly page: Page) {}

public getRoleAssignmentsCountLocator(): Locator {
return this.page.getByTestId(turnierplan.rbacOffcanvas.assignmentsCount);
}

public async close(): Promise<void> {
await this.page.getByTestId(turnierplan.rbacOffcanvas.doneButton).click();
}
}
32 changes: 30 additions & 2 deletions src/Turnierplan.App/Client/e2e/pages/view-organization-page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,38 @@ export class ViewOrganizationPage {
await this.page.getByTestId(turnierplan.createTournamentPage.confirmButton).click();
}

public async deleteOrganization(confirmText: string): Promise<void> {
public async createApiKey(name: string): Promise<{ id: string; secret: string }> {
await this.page.getByTestId(turnierplan.pageFrame.navigationTab(turnierplan.viewOrganizationPage.apiKeysPageId)).click();
await this.page.getByTestId(turnierplan.viewOrganizationPage.newApiKeyButton).click();
await this.page.getByTestId(turnierplan.createApiKeyPage.apiKeyNameField).fill(name);
await this.page.getByTestId(turnierplan.createApiKeyPage.confirmButton).click();

await this.page.getByTestId(turnierplan.createApiKeyPage.resultIdField).waitFor({ state: 'visible' });
const resultId = await this.page.getByTestId(turnierplan.createApiKeyPage.resultIdField).inputValue();
const resultSecret = await this.page.getByTestId(turnierplan.createApiKeyPage.resultSecretField).inputValue();

await this.page.getByTestId(turnierplan.createApiKeyPage.doneButton).click();

return { id: resultId, secret: resultSecret };
}

public async deleteApiKeyWithId(id: string, name: string): Promise<void> {
await this.page.getByTestId(turnierplan.pageFrame.navigationTab(turnierplan.viewOrganizationPage.apiKeysPageId)).click();
await this.page.getByTestId(turnierplan.viewOrganizationPage.deleteApiKeyButton(id)).click();
await this.page.getByTestId(turnierplan.deleteWidget.confirmationField).fill(name);
await this.page.getByTestId(turnierplan.deleteWidget.deleteButton).click();
await this.page.getByTestId(turnierplan.deleteModal.confirmDeleteButton).click();
}

public async deleteOrganization(name: string): Promise<void> {
await this.page.getByTestId(turnierplan.pageFrame.navigationTab(turnierplan.viewOrganizationPage.settingsPageId)).click();
await this.page.getByTestId(turnierplan.deleteWidget.confirmationField).fill(confirmText);
await this.page.getByTestId(turnierplan.deleteWidget.confirmationField).fill(name);
await this.page.getByTestId(turnierplan.deleteWidget.deleteButton).click();
await this.page.getByTestId(turnierplan.deleteModal.confirmDeleteButton).click();
}

public async openRoleAssignments(): Promise<void> {
await this.page.getByTestId(turnierplan.pageFrame.navigationTab(turnierplan.viewOrganizationPage.settingsPageId)).click();
await this.page.getByTestId(turnierplan.rbacWidget.openOffcanvasButton).click();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { expect, test } from '@playwright/test';
import { createIdentifier } from '../utils/create-identifier';
import { LoginPage } from '../pages/login-page';
import { LandingPage } from '../pages/landing-page';
import { ViewOrganizationPage } from '../pages/view-organization-page';
import { RbacOffcanvas } from '../pages/rbac-offcanvas';

test('Create API key, then create and remove role assignment', async ({ page }) => {
const organizationName = createIdentifier();

await new LoginPage(page).login();
await new LandingPage(page).createOrganization(organizationName);

const organizationPage = new ViewOrganizationPage(page);
const rbacOffcanvas = new RbacOffcanvas(page);

await organizationPage.openRoleAssignments();
await expect(rbacOffcanvas.getRoleAssignmentsCountLocator()).toHaveText('1 Zuweisung');
await rbacOffcanvas.close();

const apiKeyName = createIdentifier();
const apiKeyDetails = await organizationPage.createApiKey(apiKeyName);

await organizationPage.openRoleAssignments();
await expect(rbacOffcanvas.getRoleAssignmentsCountLocator()).toHaveText('2 Zuweisungen');
await rbacOffcanvas.close();

await organizationPage.deleteApiKeyWithId(apiKeyDetails.id, apiKeyName);

await organizationPage.openRoleAssignments();
await expect(rbacOffcanvas.getRoleAssignmentsCountLocator()).toHaveText('1 Zuweisung');
await rbacOffcanvas.close();
});
5 changes: 4 additions & 1 deletion src/Turnierplan.App/Client/src/app/i18n/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1393,7 +1393,10 @@ export const de = {
User: 'Benutzer'
},
PrincipalNotFound: 'Der Prinzipal konnte nicht mehr gefunden werden. Wurde der Nutzer/API-Schlüssel gelöscht?',
TotalCount: '{{count}} Zuweisungen',
TotalCount: {
One: '1 Zuweisung',
Many: '{{count}} Zuweisungen'
},
Id: 'ID:',
CreatedAt: 'Erstellt am:',
Inherited: 'Vererbt durch:',
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<div class="p-3">
<div class="mb-3">
<tp-action-button
[tpE2E]="'rbac-offcanvas-done-button'"
[title]="'Portal.General.Done'"
[icon]="'check-circle'"
[type]="'outline-secondary'"
Expand All @@ -26,11 +27,13 @@
(buttonClick)="showAddRoleAssignmentDialog()" />
</div>

@if (roleAssignmentCount > 3) {
<div class="mb-2 me-1 small text-end">
<span translate="Portal.RbacManagement.TotalCount" [translateParams]="{ count: roleAssignmentCount }"></span>
</div>
}
<div class="mb-2 me-1 small text-end fst-italic" [tpE2E]="'rbac-offcanvas-assignments-count'">
@if (roleAssignmentCount === 1) {
<span translate="Portal.RbacManagement.TotalCount.One"></span>
} @else {
<span translate="Portal.RbacManagement.TotalCount.Many" [translateParams]="{ count: roleAssignmentCount }"></span>
}
</div>

<table class="mb-0 table table-bordered">
<tbody>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { RoleAssignmentDto } from '../../../api/models/role-assignment-dto';
import { TurnierplanApi } from '../../../api/turnierplan-api';
import { deleteRoleAssignment } from '../../../api/fn/role-assignments/delete-role-assignment';
import { getRoleAssignments } from '../../../api/fn/role-assignments/get-role-assignments';
import { E2eDirective } from '../../../core/directives/e2e.directive';

interface IRbacOffcanvasTarget {
name: string;
Expand All @@ -30,7 +31,8 @@ interface IRbacOffcanvasTarget {
RbacPrincipalComponent,
DeleteButtonComponent,
TranslatePipe,
TranslateDatePipe
TranslateDatePipe,
E2eDirective
]
})
export class RbacOffcanvasComponent implements OnDestroy {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

<div class="mt-3 d-flex flex-row">
<tp-action-button
[tpE2E]="'rbac-widget-open-offcanvas-button'"
[type]="'outline-secondary'"
[icon]="'shield-lock'"
[title]="'Portal.RbacManagement.ButtonLabel'"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { filter } from 'rxjs/operators';
import { ActionButtonComponent } from '../action-button/action-button.component';
import { TranslateDirective } from '@ngx-translate/core';
import { E2eDirective } from '../../../core/directives/e2e.directive';

interface IRbacWidgetTarget {
name: string;
Expand All @@ -15,7 +16,7 @@ interface IRbacWidgetTarget {
@Component({
selector: 'tp-rbac-widget',
templateUrl: './rbac-widget.component.html',
imports: [ActionButtonComponent, TranslateDirective]
imports: [ActionButtonComponent, TranslateDirective, E2eDirective]
})
export class RbacWidgetComponent {
@Input()
Expand Down
Loading
Loading