Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
7e785ec
feat(persistence): add AuditLog entity and AddAuditLog migration
duchacekjan May 19, 2026
dcf23ed
feat(api): add AuditInterceptor + GET audit endpoint
duchacekjan May 19, 2026
9c5817e
feat(frontend): add audit TS contracts, API fn, and useWorkEntryAudit…
duchacekjan May 19, 2026
5ec5bb4
feat(frontend): add WorkEntryAuditSidebar component
duchacekjan May 19, 2026
554d74a
feat(frontend): wire history button and audit sidebar into WorkSheet
duchacekjan May 19, 2026
999f07e
fix(audit): remove duplicate close button; show deleted entry fields
duchacekjan May 19, 2026
8a574d7
fix(audit-sidebar): add padding; redesign timeline with dots on conne…
duchacekjan May 19, 2026
35fc5a6
fix(audit-sidebar): dot outline when collapsed, filled when expanded
duchacekjan May 19, 2026
20a3dda
feat(frontend): add favicon, company logo, and Home route to sidebar
duchacekjan May 19, 2026
7901d40
fix(audit): address review findings
duchacekjan May 19, 2026
d79731c
Merge branch 'main' into feature/work-entry-audit-log
duchacekjan May 20, 2026
73c97f0
Merge branch 'main' into feature/work-entry-audit-log
duchacekjan May 25, 2026
634a524
Merge branch 'main' into feature/work-entry-audit-log
duchacekjan May 25, 2026
d009f43
fix(ui): adjust IconButton size and update history icon color in Work…
duchacekjan May 25, 2026
1cf76d8
fix(ui): update IconButton variants and icon styles in WorkSheet
duchacekjan May 25, 2026
3f24a8b
fix(ui): update @itixo/component-library version and adjust TableHead…
duchacekjan May 25, 2026
4752d71
fix(ui): remove shadow styling from TableHead and TableCell in WorkSheet
duchacekjan May 25, 2026
1aebcde
Merge branch 'main' into feature/work-entry-audit-log
duchacekjan May 25, 2026
96adcc2
fix(ui): upgrade @itixo/component-library to v0.4.0 and refine TableH…
duchacekjan May 26, 2026
1f56bf0
Merge branch 'main' into feature/work-entry-audit-log
duchacekjan May 26, 2026
748412a
fix(ui): remove redundant background styles from TableHead and TableC…
duchacekjan May 27, 2026
db71985
fix(tests): correct date range calculation in WorkPage_HistoryButton …
duchacekjan May 27, 2026
e2761a5
fix audit log review issues
duchacekjan May 27, 2026
2f88c20
fix audit sidebar review issues
duchacekjan May 27, 2026
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
4 changes: 4 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,8 @@ src/
│ ├── Persistence/
│ │ ├── AppDbContext.cs # EF Core DbContext + seed data
│ │ ├── User.cs # User, Worksheet, WorkEntry entities
│ │ ├── AuditLog.cs # AuditLog entity + AuditAction enum + AuditChangedField record
│ │ ├── AuditInterceptor.cs # ISaveChangesInterceptor capturing WorkEntry mutations; registered as singleton
│ │ └── Migrations/ # EF Core migrations
│ ├── Program.cs # Composition root
│ ├── appsettings.json
Expand Down Expand Up @@ -173,6 +175,7 @@ src/
│ │ │ └── users.queries.ts
│ │ └── work/
│ │ ├── WorkSheet.tsx
│ │ ├── WorkEntryAuditSidebar.tsx # Right-side Sheet overlay with accordion audit timeline
│ │ └── worksheets.queries.ts
│ ├── pages/
│ │ ├── HomePage.tsx
Expand Down Expand Up @@ -302,6 +305,7 @@ dotnet run --project src/UCK26.Ui.Tests
- UI tests hit `/api/auth/login` once, cache JWT, inject into `localStorage` through Playwright `addInitScript`. `LoginPageTests` still cover form path.
- UI elements located via `data-test-id` — never text/title/label/role. Add `data-test-id` to anything tested.
- Every new page needs at least one Playwright test.
- Audit-related `data-test-id` values: `work-history-{date}` (history button per row), `work-history-sidebar` (audit sidebar root), `work-audit-event-{index}` (each timeline event card).

### Out of scope for demo

Expand Down
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ Demo repo for UCK26 conf session. Small full-stack app used live on stage to sho
- **Backend:** .NET 10 Minimal API, EF Core 10 + SQLite, JWT bearer auth.
- **Frontend:** React 19 + Vite + TypeScript, Tailwind v4, `@itixo/component-library`.
- **Auth:** username + password -> JWT; `Admin` role required for user management.
- **Work tracking:** monthly worksheet per user, work/holiday/doctor entries.
- **Work tracking:** monthly worksheet per user, work/holiday/doctor entries. Mutations captured in audit log.
- **Storage:** SQLite file `uck26.db`; EF migrations run at API startup.
- **Password protection:** ASP.NET Core Data Protection, keys in `./dataprotection-keys`; seed admin uses special `seed:` password hash.
- **Tests:** TUnit backend unit/integration tests; TUnit + Playwright UI tests.
Expand Down Expand Up @@ -96,6 +96,7 @@ Default admin creds:
| POST | `/api/worksheets/entries` | Any logged-in | Create work/holiday/doctor entry |
| PUT | `/api/worksheets/entries/{id}` | Owner/Admin | Update entry time + description |
| DELETE | `/api/worksheets/entries/{id}` | Owner/Admin | Delete entry |
| GET | `/api/worksheets/{year}/{month}/days/{date}/audit` | Any logged-in | Audit log for a day; admin can pass `?userId` |

## Configuration

Expand All @@ -118,6 +119,13 @@ Default admin creds:

Frontend reads `VITE_API_URL` from `.env.local`; `.env.example` points at `http://localhost:5080`.

## Database entities

- `User` — login account with role (`Admin` / `User`)
- `Worksheet` — unique per `{ UserId, Year, Month }`; cascades delete to entries
- `WorkEntry` — single time-block on a day; type `work` / `holiday` / `doctor`
- `AuditLog` — immutable mutation record for each `WorkEntry` change; **no FK to `WorkEntry`** by design so history survives entry deletion

## Reset data

Delete `src/UCK26.Api/uck26.db` or runtime `uck26.db` to reset SQLite. Delete `dataprotection-keys/` only with DB reset; existing protected passwords become unverifiable otherwise.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,11 @@ protected override void ConfigureWebHost(IWebHostBuilder builder)
services.Remove(d);
}

services.AddDbContext<AppDbContext>(opt =>
opt.UseSqlite($"Data Source={_dbPath}"));
services.AddDbContext<AppDbContext>((sp, opt) =>
{
opt.UseSqlite($"Data Source={_dbPath}");
opt.AddInterceptors(sp.GetRequiredService<AuditInterceptor>());
});

services.AddSingleton(_authState);

Expand Down
276 changes: 276 additions & 0 deletions src/UCK26.Api.Tests/Integration/WorksheetEndpointTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.DependencyInjection;
using UCK26.Api.Persistence;
using UCK26.Api.Tests.Infrastructure;

namespace UCK26.Api.Tests.Integration;
Expand Down Expand Up @@ -177,4 +179,278 @@ public async Task DeleteEntry_AsOwner_RemovesEntry()
var worksheet = await worksheetResponse.Content.ReadFromJsonAsync<JsonElement>();
await Assert.That(worksheet.GetProperty("entries").GetArrayLength()).IsEqualTo(0);
}

// Audit interceptor tests (#3)

[Test]
public async Task Audit_PostEntry_CreatesAuditLogWithCreateAction()
{
await using var factory = new TestWebApplicationFactory()
.AuthenticateAs("alice", 2, "User");
using var client = factory.CreateClient();

var response = await client.PostAsJsonAsync("/api/worksheets/entries", new
{
date = "2026-05-17",
type = "work",
start = "08:00",
end = "16:00",
description = "Day"
});
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Created);

using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var logs = db.AuditLogs.ToList();

await Assert.That(logs.Count).IsEqualTo(1);
await Assert.That(logs[0].Action).IsEqualTo(AuditAction.Create);
await Assert.That(logs[0].PerformedBy).IsEqualTo("alice");
await Assert.That(logs[0].EntryType).IsEqualTo("work");
await Assert.That(logs[0].EntryDate).IsEqualTo("2026-05-17");
await Assert.That(logs[0].ChangedFields).IsNotNull();
}

[Test]
public async Task Audit_PutEntry_CreatesUpdateLogWithChangedFields()
{
await using var factory = new TestWebApplicationFactory()
.AuthenticateAs("alice", 2, "User");
using var client = factory.CreateClient();

var create = await client.PostAsJsonAsync("/api/worksheets/entries", new
{
date = "2026-05-17",
type = "work",
start = "08:00",
end = "16:00",
description = "Before"
});
var created = await create.Content.ReadFromJsonAsync<JsonElement>();
var id = created.GetProperty("id").GetInt32();

await client.PutAsJsonAsync($"/api/worksheets/entries/{id}", new
{
start = "09:00",
end = "17:00",
description = "After"
});

using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var updateLog = db.AuditLogs.Single(l => l.Action == AuditAction.Update);

await Assert.That(updateLog.PerformedBy).IsEqualTo("alice");
await Assert.That(updateLog.EntryType).IsEqualTo("work");
await Assert.That(updateLog.ChangedFields).IsNotNull();
Comment thread
duchacekjan marked this conversation as resolved.

var fields = JsonSerializer.Deserialize<List<AuditChangedField>>(updateLog.ChangedFields!);
await Assert.That(fields).IsNotNull();
await Assert.That(fields!.Any(f => f.Field == "Description" && f.OldValue == "Before" && f.NewValue == "After")).IsTrue();
await Assert.That(fields!.Any(f => f.Field == "Start")).IsTrue();
await Assert.That(fields!.Any(f => f.Field == "End")).IsTrue();
}

[Test]
public async Task Audit_DeleteEntry_PreservesLogAfterDeletion()
{
await using var factory = new TestWebApplicationFactory()
.AuthenticateAs("alice", 2, "User");
using var client = factory.CreateClient();

var create = await client.PostAsJsonAsync("/api/worksheets/entries", new
{
date = "2026-05-20",
type = "holiday",
start = "08:00",
end = "16:00",
description = "Day off"
});
var created = await create.Content.ReadFromJsonAsync<JsonElement>();
var id = created.GetProperty("id").GetInt32();

await client.DeleteAsync($"/api/worksheets/entries/{id}");

using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var deleteLog = db.AuditLogs.Single(l => l.Action == AuditAction.Delete);

await Assert.That(deleteLog.EntryDate).IsEqualTo("2026-05-20");
await Assert.That(deleteLog.EntryType).IsEqualTo("holiday");
await Assert.That(deleteLog.WorksheetId).IsNotNull();
await Assert.That(deleteLog.ChangedFields).IsNull();
await Assert.That(db.WorkEntries.Any(e => e.Id == id)).IsFalse();
Comment thread
duchacekjan marked this conversation as resolved.
}

// Audit endpoint tests (#4)

[Test]
public async Task GetAudit_NoWorksheet_Returns200EmptyArray()
{
await using var factory = new TestWebApplicationFactory()
.AuthenticateAs("alice", 2, "User");
using var client = factory.CreateClient();

var response = await client.GetAsync("/api/worksheets/2026/5/days/2026-05-17/audit");
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK);

var logs = await response.Content.ReadFromJsonAsync<JsonElement>();
await Assert.That(logs.GetArrayLength()).IsEqualTo(0);
}

[Test]
public async Task GetAudit_AfterPostEntry_ReturnsCreateEvent()
{
await using var factory = new TestWebApplicationFactory()
.AuthenticateAs("alice", 2, "User");
using var client = factory.CreateClient();

await client.PostAsJsonAsync("/api/worksheets/entries", new
{
date = "2026-05-17",
type = "work",
start = "08:00",
end = "16:00",
description = "Day"
});

var response = await client.GetAsync("/api/worksheets/2026/5/days/2026-05-17/audit");
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK);

var logs = await response.Content.ReadFromJsonAsync<JsonElement>();
await Assert.That(logs.GetArrayLength()).IsEqualTo(1);
await Assert.That(logs[0].GetProperty("action").GetString()).IsEqualTo("Create");
await Assert.That(logs[0].GetProperty("entryType").GetString()).IsEqualTo("work");
}

[Test]
public async Task GetAudit_AfterPostAndPut_ReturnsTwoEventsDescending()
{
await using var factory = new TestWebApplicationFactory()
.AuthenticateAs("alice", 2, "User");
using var client = factory.CreateClient();

var create = await client.PostAsJsonAsync("/api/worksheets/entries", new
{
date = "2026-05-17",
type = "work",
start = "08:00",
end = "16:00",
description = "Day"
});
var created = await create.Content.ReadFromJsonAsync<JsonElement>();
var id = created.GetProperty("id").GetInt32();

await client.PutAsJsonAsync($"/api/worksheets/entries/{id}", new
{
start = "09:00",
end = "17:00",
description = "Updated"
});

using (var scope = factory.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var sameTime = DateTime.UtcNow;
foreach (var log in db.AuditLogs)
log.PerformedAt = sameTime;
await db.SaveChangesAsync();
}

var response = await client.GetAsync("/api/worksheets/2026/5/days/2026-05-17/audit");
var logs = await response.Content.ReadFromJsonAsync<JsonElement>();

await Assert.That(logs.GetArrayLength()).IsEqualTo(2);
await Assert.That(logs[0].GetProperty("action").GetString()).IsEqualTo("Update");
await Assert.That(logs[0].GetProperty("entryType").GetString()).IsEqualTo("work");
await Assert.That(logs[0].GetProperty("changedFields").GetArrayLength()).IsGreaterThan(0);
await Assert.That(logs[1].GetProperty("action").GetString()).IsEqualTo("Create");
}

[Test]
public async Task GetAudit_WithInvalidChangedFields_ReturnsNullChangedFields()
{
await using var factory = new TestWebApplicationFactory()
.AuthenticateAs("alice", 2, "User");
using var client = factory.CreateClient();

await client.PostAsJsonAsync("/api/worksheets/entries", new
{
date = "2026-05-17",
type = "work",
start = "08:00",
end = "16:00",
description = "Day"
});

using (var scope = factory.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var log = db.AuditLogs.Single();
log.ChangedFields = "not json";
await db.SaveChangesAsync();
}

var response = await client.GetAsync("/api/worksheets/2026/5/days/2026-05-17/audit");
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK);

var logs = await response.Content.ReadFromJsonAsync<JsonElement>();
await Assert.That(logs[0].GetProperty("changedFields").ValueKind).IsEqualTo(JsonValueKind.Null);
}

[Test]
public async Task GetAudit_NonAdmin_WithOtherUserId_ReturnsOwnData()
{
await using var factory = new TestWebApplicationFactory()
.AuthenticateAs("alice", 2, "User");
using var client = factory.CreateClient();

await client.PostAsJsonAsync("/api/worksheets/entries", new
{
date = "2026-05-17",
type = "work",
start = "08:00",
end = "16:00",
description = "Mine"
});

var response = await client.GetAsync("/api/worksheets/2026/5/days/2026-05-17/audit?userId=99");
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK);

var logs = await response.Content.ReadFromJsonAsync<JsonElement>();
await Assert.That(logs.GetArrayLength()).IsEqualTo(1);
}

[Test]
public async Task GetAudit_Admin_WithUserId_ReturnsTargetUserData()
{
await using var factory = new TestWebApplicationFactory()
.AuthenticateAs("admin", 1, "Admin");
using var client = factory.CreateClient();

var createUser = await client.PostAsJsonAsync("/api/users", new
{
userName = "worker2",
password = "Worker!2026",
role = "User"
});
var createdUser = await createUser.Content.ReadFromJsonAsync<JsonElement>();
var userId = createdUser.GetProperty("id").GetInt32();

await client.PostAsJsonAsync("/api/worksheets/entries", new
{
date = "2026-05-17",
type = "work",
start = "08:00",
end = "16:00",
description = "Admin entry",
userId = userId
});

var response = await client.GetAsync($"/api/worksheets/2026/5/days/2026-05-17/audit?userId={userId}");
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK);

var logs = await response.Content.ReadFromJsonAsync<JsonElement>();
await Assert.That(logs.GetArrayLength()).IsEqualTo(1);
}
}
Loading