Compare commits

...

3 Commits

Author SHA1 Message Date
tim
118660a207 Merge pull request 'Tech-Debt-Cleanup: Doku, Security-Hardening, Server-Validierung, Client-Stabilitaet' (#2) from feature/techdebt-cleanup into master
Some checks failed
Build / build (push) Successful in 2m11s
OpenWiki Update / update (push) Has been cancelled
Reviewed-on: #2
2026-09-01 19:00:25 +00:00
Tim Krampitz
7953b8ddd0 P1: Challenger-Zugehörigkeit zum Spiel validieren; P2: MAUI-Workload im CI installieren
All checks were successful
Build / build (pull_request) Successful in 6m10s
- GameService.CreateChallengeAsync prüft jetzt, dass challengingPlayerId zum gameId gehört
- Zwei neue Tests für Cross-Game-/Fremdspieler-Challenges
- CI: dotnet workload install maui vor dem Restore, damit MAUI auf frischen Runnern baut
2026-09-01 20:50:46 +02:00
Tim Krampitz
660fc4d311 Fix CI build workflow, index casing, and test assertions
All checks were successful
Build / build (pull_request) Successful in 2m16s
2026-08-23 14:46:09 +02:00
7 changed files with 97 additions and 37 deletions

View File

@@ -19,8 +19,22 @@ jobs:
with: with:
dotnet-version: "10.0.x" dotnet-version: "10.0.x"
- name: Install MAUI workload
run: dotnet workload install maui
- name: Restore - name: Restore
run: dotnet restore SlipItIn.slnx run: dotnet restore SlipItIn.slnx
- name: Build - name: Build Server-Stack
run: dotnet build SlipItIn.slnx --no-restore --configuration Release run: |
dotnet build SlipItIn.Shared/SlipItIn.Shared.csproj --no-restore --configuration Release
dotnet build SlipItIn.ServiceDefaults/SlipItIn.ServiceDefaults.csproj --no-restore --configuration Release
dotnet build SlipItIn.Server/SlipItIn.Server.csproj --no-restore --configuration Release
dotnet build SlipItIn.Server.Tests/SlipItIn.Server.Tests.csproj --no-restore --configuration Release
dotnet build SlipItIn.AppHost/SlipItIn.AppHost.csproj --no-restore --configuration Release
- name: Build MAUI Client
run: dotnet build SlipItIn/SlipItIn.csproj --no-restore --configuration Debug
- name: Run Server Tests
run: dotnet test SlipItIn.Server.Tests/SlipItIn.Server.Tests.csproj --no-restore --configuration Release

View File

@@ -1,10 +0,0 @@
<Solution>
<Project Path="SlipItIn.AppHost/SlipItIn.AppHost.csproj" />
<Project Path="SlipItIn.Server.Tests/SlipItIn.Server.Tests.csproj" />
<Project Path="SlipItIn.Server/SlipItIn.Server.csproj" />
<Project Path="SlipItIn.ServiceDefaults/SlipItIn.ServiceDefaults.csproj" />
<Project Path="SlipItIn.Shared/SlipItIn.Shared.csproj" />
<Project Path="SlipItIn/SlipItIn.csproj">
<Deploy Solution="Debug|*" />
</Project>
</Solution>

View File

@@ -1,6 +1,7 @@
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting;
using SlipItIn.Server.Controllers; using SlipItIn.Server.Controllers;
using SlipItIn.Server.Data; using SlipItIn.Server.Data;
using SlipItIn.Shared.DTOs; using SlipItIn.Shared.DTOs;
@@ -26,7 +27,15 @@ public class AuthControllerTests
["Jwt:ExpirationMinutes"] = "60" ["Jwt:ExpirationMinutes"] = "60"
}) })
.Build(); .Build();
_sut = new AuthController(_factory, config); _sut = new AuthController(_factory, config, new TestHostEnvironment());
}
private sealed class TestHostEnvironment : IHostEnvironment
{
public string EnvironmentName { get; set; } = Environments.Development;
public string ApplicationName { get; set; } = "SlipItIn.Server.Tests";
public string ContentRootPath { get; set; } = AppContext.BaseDirectory;
public Microsoft.Extensions.FileProviders.IFileProvider ContentRootFileProvider { get; set; } = null!;
} }
// ---------- Register ---------- // ---------- Register ----------

View File

@@ -117,7 +117,10 @@ public class GameServiceTests
{ {
using var db = _factory.CreateDbContext(); using var db = _factory.CreateDbContext();
var host = TestDbHelper.SeedUser(db); var host = TestDbHelper.SeedUser(db);
var other = TestDbHelper.SeedUser(db, "other", "other@t.local");
var (game, _) = TestDbHelper.SeedGame(db, host); var (game, _) = TestDbHelper.SeedGame(db, host);
db.Players.Add(new Player { UserId = other.Id, GameId = game.Id });
await db.SaveChangesAsync();
var result = await _sut.StartGameAsync(game.Id); var result = await _sut.StartGameAsync(game.Id);
@@ -216,7 +219,7 @@ public class GameServiceTests
{ {
using var db = _factory.CreateDbContext(); using var db = _factory.CreateDbContext();
var host = TestDbHelper.SeedUser(db); var host = TestDbHelper.SeedUser(db);
var (game, hostPlayer) = TestDbHelper.SeedGame(db, host); var (game, hostPlayer) = TestDbHelper.SeedGame(db, host, GameStatus.InProgress);
var round = new GameRound { GameId = game.Id, RoundNumber = 1, Status = RoundStatus.Active }; var round = new GameRound { GameId = game.Id, RoundNumber = 1, Status = RoundStatus.Active };
db.GameRounds.Add(round); db.GameRounds.Add(round);
var phrase = TestDbHelper.SeedPhrases(db, 1).Single(); var phrase = TestDbHelper.SeedPhrases(db, 1).Single();
@@ -234,7 +237,11 @@ public class GameServiceTests
[Fact] [Fact]
public async Task SubmitSlipAsync_Throws_WhenCardNotFound() public async Task SubmitSlipAsync_Throws_WhenCardNotFound()
{ {
await Assert.ThrowsAsync<InvalidOperationException>(() => _sut.SubmitSlipAsync(1, 1, 999)); using var db = _factory.CreateDbContext();
var host = TestDbHelper.SeedUser(db);
var (game, _) = TestDbHelper.SeedGame(db, host, GameStatus.InProgress);
await Assert.ThrowsAsync<InvalidOperationException>(() => _sut.SubmitSlipAsync(game.Id, 1, 999));
} }
[Fact] [Fact]
@@ -243,7 +250,7 @@ public class GameServiceTests
using var db = _factory.CreateDbContext(); using var db = _factory.CreateDbContext();
var host = TestDbHelper.SeedUser(db, "host", "host@t.local"); var host = TestDbHelper.SeedUser(db, "host", "host@t.local");
var other = TestDbHelper.SeedUser(db, "other", "other@t.local"); var other = TestDbHelper.SeedUser(db, "other", "other@t.local");
var (game, hostPlayer) = TestDbHelper.SeedGame(db, host); var (game, hostPlayer) = TestDbHelper.SeedGame(db, host, GameStatus.InProgress);
var otherPlayer = new Player { UserId = other.Id, GameId = game.Id }; var otherPlayer = new Player { UserId = other.Id, GameId = game.Id };
db.Players.Add(otherPlayer); db.Players.Add(otherPlayer);
var round = new GameRound { GameId = game.Id, RoundNumber = 1, Status = RoundStatus.Active }; var round = new GameRound { GameId = game.Id, RoundNumber = 1, Status = RoundStatus.Active };
@@ -271,7 +278,7 @@ public class GameServiceTests
var round = new GameRound { GameId = game.Id, RoundNumber = 1, Status = RoundStatus.Active }; var round = new GameRound { GameId = game.Id, RoundNumber = 1, Status = RoundStatus.Active };
db.GameRounds.Add(round); db.GameRounds.Add(round);
var phrase = TestDbHelper.SeedPhrases(db, 1).Single(); var phrase = TestDbHelper.SeedPhrases(db, 1).Single();
var card = new PlayerCard { PlayerId = otherPlayer.Id, PhraseId = phrase.Id, GameRoundId = round.Id }; var card = new PlayerCard { PlayerId = otherPlayer.Id, PhraseId = phrase.Id, GameRoundId = round.Id, IsUsed = true };
db.PlayerCards.Add(card); db.PlayerCards.Add(card);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
@@ -306,10 +313,55 @@ public class GameServiceTests
await Assert.ThrowsAsync<InvalidOperationException>(() => _sut.CreateChallengeAsync(game.Id, hostPlayer.Id, card.Id)); await Assert.ThrowsAsync<InvalidOperationException>(() => _sut.CreateChallengeAsync(game.Id, hostPlayer.Id, card.Id));
} }
[Fact]
public async Task CreateChallengeAsync_Throws_WhenChallengerNotInGame()
{
using var db = _factory.CreateDbContext();
var host = TestDbHelper.SeedUser(db, "host", "host@t.local");
var other = TestDbHelper.SeedUser(db, "other", "other@t.local");
var outsider = TestDbHelper.SeedUser(db, "outsider", "outsider@t.local");
var (game, hostPlayer) = TestDbHelper.SeedGame(db, host);
var otherPlayer = new Player { UserId = other.Id, GameId = game.Id };
db.Players.Add(otherPlayer);
var round = new GameRound { GameId = game.Id, RoundNumber = 1, Status = RoundStatus.Active };
db.GameRounds.Add(round);
var phrase = TestDbHelper.SeedPhrases(db, 1).Single();
var card = new PlayerCard { PlayerId = otherPlayer.Id, PhraseId = phrase.Id, GameRoundId = round.Id, IsUsed = true };
db.PlayerCards.Add(card);
await db.SaveChangesAsync();
// outsider ist nicht in diesem Spiel und versucht eine Challenge anzulegen
await Assert.ThrowsAsync<UnauthorizedAccessException>(
() => _sut.CreateChallengeAsync(game.Id, hostPlayer.Id + 1000, card.Id));
}
[Fact]
public async Task CreateChallengeAsync_Throws_WhenChallengerFromOtherGame()
{
using var db = _factory.CreateDbContext();
var hostA = TestDbHelper.SeedUser(db, "hostA", "hostA@t.local");
var hostB = TestDbHelper.SeedUser(db, "hostB", "hostB@t.local");
var other = TestDbHelper.SeedUser(db, "other", "other@t.local");
var (gameA, hostPlayerA) = TestDbHelper.SeedGame(db, hostA);
var (gameB, _) = TestDbHelper.SeedGame(db, hostB);
var otherPlayerB = new Player { UserId = other.Id, GameId = gameB.Id };
db.Players.Add(otherPlayerB);
var roundB = new GameRound { GameId = gameB.Id, RoundNumber = 1, Status = RoundStatus.Active };
db.GameRounds.Add(roundB);
var phrase = TestDbHelper.SeedPhrases(db, 1).Single();
var cardB = new PlayerCard { PlayerId = otherPlayerB.Id, PhraseId = phrase.Id, GameRoundId = roundB.Id, IsUsed = true };
db.PlayerCards.Add(cardB);
await db.SaveChangesAsync();
// hostPlayerA gehört zu gameA, versucht aber eine Karte aus gameB zu challengen
await Assert.ThrowsAsync<UnauthorizedAccessException>(
() => _sut.CreateChallengeAsync(gameB.Id, hostPlayerA.Id, cardB.Id));
}
// ---------- ResolveChallengeAsync ---------- // ---------- ResolveChallengeAsync ----------
[Fact] [Fact]
public async Task ResolveChallengeAsync_Approved_KeepsCardWithTargetPlayer() public async Task ResolveChallengeAsync_Approved_TransfersCardToChallenger()
{ {
using var db = _factory.CreateDbContext(); using var db = _factory.CreateDbContext();
var host = TestDbHelper.SeedUser(db, "host", "host@t.local"); var host = TestDbHelper.SeedUser(db, "host", "host@t.local");
@@ -320,7 +372,7 @@ public class GameServiceTests
var round = new GameRound { GameId = game.Id, RoundNumber = 1, Status = RoundStatus.Active }; var round = new GameRound { GameId = game.Id, RoundNumber = 1, Status = RoundStatus.Active };
db.GameRounds.Add(round); db.GameRounds.Add(round);
var phrase = TestDbHelper.SeedPhrases(db, 1).Single(); var phrase = TestDbHelper.SeedPhrases(db, 1).Single();
var card = new PlayerCard { PlayerId = otherPlayer.Id, PhraseId = phrase.Id, GameRoundId = round.Id }; var card = new PlayerCard { PlayerId = otherPlayer.Id, PhraseId = phrase.Id, GameRoundId = round.Id, IsUsed = true };
db.PlayerCards.Add(card); db.PlayerCards.Add(card);
var challenge = new SlipChallenge var challenge = new SlipChallenge
{ {
@@ -337,13 +389,13 @@ public class GameServiceTests
Assert.Equal(ChallengeStatus.Approved, result.Status); Assert.Equal(ChallengeStatus.Approved, result.Status);
Assert.NotNull(result.ResolvedAt); Assert.NotNull(result.ResolvedAt);
// Karte bleibt beim Beschuldigten // Berechtigte Beschuldigung: Karte geht an den Beschuldiger
using var verify = _factory.CreateDbContext(); using var verify = _factory.CreateDbContext();
Assert.Equal(otherPlayer.Id, verify.PlayerCards.Single(c => c.Id == card.Id).PlayerId); Assert.Equal(hostPlayer.Id, verify.PlayerCards.Single(c => c.Id == card.Id).PlayerId);
} }
[Fact] [Fact]
public async Task ResolveChallengeAsync_Rejected_TransfersCardToChallenger() public async Task ResolveChallengeAsync_Rejected_KeepsCardWithTargetPlayer()
{ {
using var db = _factory.CreateDbContext(); using var db = _factory.CreateDbContext();
var host = TestDbHelper.SeedUser(db, "host", "host@t.local"); var host = TestDbHelper.SeedUser(db, "host", "host@t.local");
@@ -354,7 +406,7 @@ public class GameServiceTests
var round = new GameRound { GameId = game.Id, RoundNumber = 1, Status = RoundStatus.Active }; var round = new GameRound { GameId = game.Id, RoundNumber = 1, Status = RoundStatus.Active };
db.GameRounds.Add(round); db.GameRounds.Add(round);
var phrase = TestDbHelper.SeedPhrases(db, 1).Single(); var phrase = TestDbHelper.SeedPhrases(db, 1).Single();
var card = new PlayerCard { PlayerId = otherPlayer.Id, PhraseId = phrase.Id, GameRoundId = round.Id }; var card = new PlayerCard { PlayerId = otherPlayer.Id, PhraseId = phrase.Id, GameRoundId = round.Id, IsUsed = true };
db.PlayerCards.Add(card); db.PlayerCards.Add(card);
var challenge = new SlipChallenge var challenge = new SlipChallenge
{ {
@@ -371,9 +423,9 @@ public class GameServiceTests
Assert.Equal(ChallengeStatus.Rejected, result.Status); Assert.Equal(ChallengeStatus.Rejected, result.Status);
Assert.NotNull(result.ResolvedAt); Assert.NotNull(result.ResolvedAt);
// Karte geht an den Beschuldiger // Falsche Beschuldigung: Karte bleibt beim Beschuldigten
using var verify = _factory.CreateDbContext(); using var verify = _factory.CreateDbContext();
Assert.Equal(hostPlayer.Id, verify.PlayerCards.Single(c => c.Id == card.Id).PlayerId); Assert.Equal(otherPlayer.Id, verify.PlayerCards.Single(c => c.Id == card.Id).PlayerId);
} }
[Fact] [Fact]
@@ -458,7 +510,7 @@ public class GameServiceTests
using var db = _factory.CreateDbContext(); using var db = _factory.CreateDbContext();
var host = TestDbHelper.SeedUser(db); var host = TestDbHelper.SeedUser(db);
var (game, hostPlayer) = TestDbHelper.SeedGame(db, host); var (game, hostPlayer) = TestDbHelper.SeedGame(db, host);
var round = new GameRound { GameId = game.Id, RoundNumber = 1 }; var round = new GameRound { GameId = game.Id, RoundNumber = 1, Status = RoundStatus.Active };
db.GameRounds.Add(round); db.GameRounds.Add(round);
var phrases = TestDbHelper.SeedPhrases(db, 2); var phrases = TestDbHelper.SeedPhrases(db, 2);
db.PlayerCards.Add(new PlayerCard { PlayerId = hostPlayer.Id, PhraseId = phrases[0].Id, GameRoundId = round.Id }); db.PlayerCards.Add(new PlayerCard { PlayerId = hostPlayer.Id, PhraseId = phrases[0].Id, GameRoundId = round.Id });

View File

@@ -12,7 +12,7 @@
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.11" /> <PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.10" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.9.0" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.9.0" />
<PackageReference Include="Moq" Version="4.20.72" /> <PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="xunit" Version="2.9.3" /> <PackageReference Include="xunit" Version="2.9.3" />

View File

@@ -243,11 +243,6 @@ public class GameHub : Hub
{ {
await Clients.Caller.SendAsync("Error", new { Message = ex.Message }); await Clients.Caller.SendAsync("Error", new { Message = ex.Message });
} }
catch (InvalidOperationException ex)
{
// Geprüfte Fachfehler (z. B. "Round is not active") dürfen an den Client.
await Clients.Caller.SendAsync("Error", new { Message = ex.Message });
}
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "Error submitting slip"); _logger.LogError(ex, "Error submitting slip");
@@ -294,11 +289,6 @@ public class GameHub : Hub
{ {
await Clients.Caller.SendAsync("Error", new { Message = ex.Message }); await Clients.Caller.SendAsync("Error", new { Message = ex.Message });
} }
catch (InvalidOperationException ex)
{
// Geprüfte Fachfehler (z. B. "Card does not belong to the active round") dürfen an den Client.
await Clients.Caller.SendAsync("Error", new { Message = ex.Message });
}
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "Error challenging slip"); _logger.LogError(ex, "Error challenging slip");

View File

@@ -176,6 +176,11 @@ public class GameService : IGameService
.Where(gr => gr.GameId == gameId && gr.Status == RoundStatus.Active) .Where(gr => gr.GameId == gameId && gr.Status == RoundStatus.Active)
.FirstOrDefaultAsync() ?? throw new InvalidOperationException("No active round found"); .FirstOrDefaultAsync() ?? throw new InvalidOperationException("No active round found");
var challengerBelongsToGame = await context.Players
.AnyAsync(p => p.Id == challengingPlayerId && p.GameId == gameId);
if (!challengerBelongsToGame)
throw new UnauthorizedAccessException("Challenger does not belong to this game");
var targetCard = await context.PlayerCards var targetCard = await context.PlayerCards
.FirstOrDefaultAsync(pc => pc.Id == targetCardId) ?? throw new InvalidOperationException("Target card not found"); .FirstOrDefaultAsync(pc => pc.Id == targetCardId) ?? throw new InvalidOperationException("Target card not found");