diff --git a/.gitignore b/.gitignore
index 065c614..7292033 100644
--- a/.gitignore
+++ b/.gitignore
@@ -11,8 +11,9 @@ obj/
*.log
# VS Code
-.vscode/
-!.vscode/extensions.json
+#.vscode/
+#!.vscode/extensions.json
+.vscode/extensions.json
!.vscode/launch.json
!.vscode/tasks.json
diff --git a/.vscode/docker-compose.debug.yml b/.vscode/docker-compose.debug.yml
new file mode 100644
index 0000000..ff69307
--- /dev/null
+++ b/.vscode/docker-compose.debug.yml
@@ -0,0 +1,9 @@
+services:
+ worktracker:
+ entrypoint:
+ - /bin/sh
+ - -c
+ command:
+ - while sleep 1000; do :; done
+ healthcheck:
+ disable: true
\ No newline at end of file
diff --git a/.vscode/launch.json b/.vscode/launch.json
new file mode 100644
index 0000000..2b0caf4
--- /dev/null
+++ b/.vscode/launch.json
@@ -0,0 +1,61 @@
+{
+ "version": "0.2.0",
+ "compounds": [
+ {
+ "name": "WorkTracker: Debug in Docker",
+ "configurations": [
+ "WorkTracker: Debug App in Docker",
+ "WorkTracker: Debug Edge"
+ ],
+ "stopAll": true
+ }
+ ],
+ "configurations": [
+ {
+ "name": "WorkTracker: Debug App in Docker",
+ "type": "coreclr",
+ "request": "launch",
+ "preLaunchTask": "WorkTracker: Docker Debug Prepare",
+ "postDebugTask": "WorkTracker: Docker Debug Down",
+ "program": "/workspace/bin/Debug/net10.0/WorkTracker.dll",
+ "args": [
+ "--urls",
+ "http://+:8080"
+ ],
+ "cwd": "/workspace",
+ "env": {
+ "ASPNETCORE_ENVIRONMENT": "Development",
+ "ASPNETCORE_URLS": "http://+:8080",
+ "DOTNET_USE_POLLING_FILE_WATCHER": "1",
+ "UseHttpsRedirection": "false"
+ },
+ "sourceFileMap": {
+ "/workspace": "${workspaceFolder}"
+ },
+ "pipeTransport": {
+ "pipeProgram": "docker",
+ "pipeArgs": [
+ "exec",
+ "-i",
+ "worktracker-dev",
+ "sh",
+ "-c"
+ ],
+ "debuggerPath": "/vsdbg/vsdbg",
+ "pipeCwd": "${workspaceFolder}",
+ "quoteArgs": false
+ },
+ "justMyCode": true,
+ "requireExactSource": false,
+ "console": "internalConsole"
+ },
+ {
+ "name": "WorkTracker: Debug Edge",
+ "type": "msedge",
+ "request": "launch",
+ "url": "http://localhost:8002",
+ "webRoot": "${workspaceFolder}",
+ "internalConsoleOptions": "neverOpen"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/.vscode/tasks.json b/.vscode/tasks.json
new file mode 100644
index 0000000..ef494e7
--- /dev/null
+++ b/.vscode/tasks.json
@@ -0,0 +1,113 @@
+{
+ "version": "2.0.0",
+ "tasks": [
+ {
+ "label": "WorkTracker: Docker Debug Up",
+ "type": "shell",
+ "command": "docker",
+ "args": [
+ "compose",
+ "-f",
+ "docker-compose.yml",
+ "-f",
+ "docker-compose.override.yml",
+ "-f",
+ ".vscode/docker-compose.debug.yml",
+ "up",
+ "-d",
+ "--build",
+ "worktracker"
+ ],
+ "options": {
+ "cwd": "${workspaceFolder}"
+ },
+ "problemMatcher": []
+ },
+ {
+ "label": "WorkTracker: Docker Debug Build",
+ "type": "shell",
+ "command": "docker",
+ "args": [
+ "exec",
+ "worktracker-dev",
+ "dotnet",
+ "build",
+ "WorkTracker.csproj",
+ "-c",
+ "Debug"
+ ],
+ "options": {
+ "cwd": "${workspaceFolder}"
+ },
+ "problemMatcher": "$msCompile"
+ },
+ {
+ "label": "WorkTracker: Docker Debug Prepare",
+ "dependsOrder": "sequence",
+ "dependsOn": [
+ "WorkTracker: Docker Debug Up",
+ "WorkTracker: Docker Debug Build"
+ ],
+ "problemMatcher": []
+ },
+ {
+ "label": "WorkTracker: Docker Debug Down",
+ "type": "shell",
+ "command": "docker",
+ "args": [
+ "compose",
+ "-f",
+ "docker-compose.yml",
+ "-f",
+ "docker-compose.override.yml",
+ "-f",
+ ".vscode/docker-compose.debug.yml",
+ "down"
+ ],
+ "options": {
+ "cwd": "${workspaceFolder}"
+ },
+ "problemMatcher": []
+ },
+ {
+ "label": "WorkTracker: Docker Playwright Tests",
+ "type": "shell",
+ "command": "docker",
+ "args": [
+ "compose",
+ "-f",
+ "docker-compose.yml",
+ "-f",
+ "docker-compose.tests.yml",
+ "up",
+ "--build",
+ "--abort-on-container-exit",
+ "--exit-code-from",
+ "playwright",
+ "playwright"
+ ],
+ "options": {
+ "cwd": "${workspaceFolder}"
+ },
+ "problemMatcher": []
+ },
+ {
+ "label": "WorkTracker: Docker Playwright Down",
+ "type": "shell",
+ "command": "docker",
+ "args": [
+ "compose",
+ "-f",
+ "docker-compose.yml",
+ "-f",
+ "docker-compose.tests.yml",
+ "down",
+ "--volumes"
+ ],
+ "options": {
+ "cwd": "${workspaceFolder}"
+ },
+ "problemMatcher": []
+ }
+ ]
+}
\ No newline at end of file
diff --git a/Components/Layout/NavMenu.razor b/Components/Layout/NavMenu.razor
index 3ff3c9a..fe28638 100644
--- a/Components/Layout/NavMenu.razor
+++ b/Components/Layout/NavMenu.razor
@@ -1,4 +1,8 @@
-
+@using Microsoft.Extensions.Options
+@using WorkTracker.Configuration
+@inject IOptions
AppAuthOptions
+
+
@@ -51,13 +55,16 @@
@context.User.Identity?.Name
-
-
-
+ @if (AppAuthOptions.Value.Enabled)
+ {
+
+
+
+ }
diff --git a/Components/Pages/ChangePassword.razor b/Components/Pages/ChangePassword.razor
index 1a96b8a..d6938aa 100644
--- a/Components/Pages/ChangePassword.razor
+++ b/Components/Pages/ChangePassword.razor
@@ -3,9 +3,20 @@
@using Microsoft.AspNetCore.Authorization
@using Microsoft.AspNetCore.Components
+@using Microsoft.Extensions.Options
+@using WorkTracker.Configuration
+
+@inject IOptions
AppAuthOptions
+@inject NavigationManager Navigation
Change Password
+@if (!AppAuthOptions.Value.Enabled)
+{
+ Redirecting...
+}
+else
+{
Change password
@if (!string.IsNullOrWhiteSpace(Error))
@@ -26,8 +37,17 @@
+}
@code {
[SupplyParameterFromQuery]
public string? Error { get; set; }
+
+ protected override void OnInitialized()
+ {
+ if (!AppAuthOptions.Value.Enabled)
+ {
+ Navigation.NavigateTo("/", forceLoad: true);
+ }
+ }
}
diff --git a/Components/Pages/Login.razor b/Components/Pages/Login.razor
index cf4641d..d6c0749 100644
--- a/Components/Pages/Login.razor
+++ b/Components/Pages/Login.razor
@@ -3,9 +3,20 @@
@using Microsoft.AspNetCore.Authorization
@using Microsoft.AspNetCore.Components
+@using Microsoft.Extensions.Options
+@using WorkTracker.Configuration
+
+@inject IOptions AppAuthOptions
+@inject NavigationManager Navigation
Login
+@if (!AppAuthOptions.Value.Enabled)
+{
+ Redirecting...
+}
+else
+{
Login
@if (!string.IsNullOrWhiteSpace(Error))
@@ -28,6 +39,7 @@
+}
@code {
[SupplyParameterFromQuery]
@@ -39,6 +51,14 @@
[SupplyParameterFromQuery]
public string? Username { get; set; }
+ protected override void OnInitialized()
+ {
+ if (!AppAuthOptions.Value.Enabled)
+ {
+ Navigation.NavigateTo(SafeReturnUrl, forceLoad: true);
+ }
+ }
+
private string SafeReturnUrl =>
string.IsNullOrWhiteSpace(ReturnUrl) || !Uri.IsWellFormedUriString(ReturnUrl, UriKind.Relative)
? "/"
diff --git a/Configuration/AppAuthOptions.cs b/Configuration/AppAuthOptions.cs
new file mode 100644
index 0000000..8868f8d
--- /dev/null
+++ b/Configuration/AppAuthOptions.cs
@@ -0,0 +1,12 @@
+namespace WorkTracker.Configuration;
+
+public sealed class AppAuthOptions
+{
+ public const string SectionName = "AppAuth";
+
+ public bool Enabled { get; init; } = false;
+
+ public string DefaultUsername { get; init; } = "Admin";
+
+ public string DefaultUserId { get; init; } = "ADMIN";
+}
\ No newline at end of file
diff --git a/Dockerfile b/Dockerfile
index 41354d4..637be0f 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -36,6 +36,6 @@ EXPOSE 8080
VOLUME ["/data/couchbase"]
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
- CMD wget -qO- http://127.0.0.1:8080/ >/dev/null 2>&1 || exit 1
+ CMD wget -qO- http://127.0.0.1:8080/healthz >/dev/null 2>&1 || exit 1
ENTRYPOINT ["dotnet", "WorkTracker.dll"]
diff --git a/Dockerfile.playwright b/Dockerfile.playwright
new file mode 100644
index 0000000..4f3a27a
--- /dev/null
+++ b/Dockerfile.playwright
@@ -0,0 +1,10 @@
+FROM mcr.microsoft.com/playwright:v1.59.1-noble
+
+WORKDIR /workspace
+
+COPY package.json playwright.config.ts ./
+COPY tests ./tests
+
+RUN npm install --no-fund --no-audit
+
+CMD ["npx", "playwright", "test"]
\ No newline at end of file
diff --git a/Program.cs b/Program.cs
index 758e924..68c4cec 100644
--- a/Program.cs
+++ b/Program.cs
@@ -1,6 +1,7 @@
using System.Globalization;
using System.Security.Claims;
using NLog.Web;
+using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Components.Authorization;
@@ -25,18 +26,32 @@ builder.Host.UseNLog();
builder.Services.AddRazorComponents()
.AddInteractiveServerComponents();
+builder.Services.Configure(builder.Configuration.GetSection(AppAuthOptions.SectionName));
+var appAuthOptions = builder.Configuration.GetSection(AppAuthOptions.SectionName).Get() ?? new AppAuthOptions();
+var authenticationEnabled = appAuthOptions.Enabled;
+
builder.Services.AddCascadingAuthenticationState();
builder.Services.AddAuthorization();
-builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
- .AddCookie(options =>
- {
- options.LoginPath = "/login";
- options.LogoutPath = "/logout";
- options.AccessDeniedPath = "/login";
- options.SlidingExpiration = true;
- options.ExpireTimeSpan = TimeSpan.FromDays(14);
- });
+if (authenticationEnabled)
+{
+ builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
+ .AddCookie(options =>
+ {
+ options.LoginPath = "/login";
+ options.LogoutPath = "/logout";
+ options.AccessDeniedPath = "/login";
+ options.SlidingExpiration = true;
+ options.ExpireTimeSpan = TimeSpan.FromDays(14);
+ });
+}
+else
+{
+ builder.Services.AddAuthentication(DefaultAdminAuthenticationHandler.SchemeName)
+ .AddScheme(
+ DefaultAdminAuthenticationHandler.SchemeName,
+ static _ => { });
+}
builder.Services.AddLocalization();
@@ -86,14 +101,29 @@ app.UseAuthorization();
app.UseAntiforgery();
+static string GetSafeReturnUrl(string? returnUrl)
+{
+ return string.IsNullOrWhiteSpace(returnUrl) || !Uri.IsWellFormedUriString(returnUrl, UriKind.Relative)
+ ? "/"
+ : returnUrl;
+}
+
app.MapPost("/api/login", async (HttpContext context) =>
{
+ var form = await context.Request.ReadFormAsync();
+ var returnUrl = form["returnUrl"].ToString();
+ var safeReturnUrl = GetSafeReturnUrl(returnUrl);
+
+ if (!authenticationEnabled)
+ {
+ context.Response.Redirect(safeReturnUrl);
+ return;
+ }
+
var authService = context.RequestServices.GetRequiredService();
var logger = context.RequestServices.GetRequiredService().CreateLogger("Auth.Login");
- var form = await context.Request.ReadFormAsync();
var username = form["username"].ToString();
var password = form["password"].ToString();
- var returnUrl = form["returnUrl"].ToString();
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
{
@@ -125,16 +155,18 @@ app.MapPost("/api/login", async (HttpContext context) =>
return;
}
- var safeReturnUrl = string.IsNullOrWhiteSpace(returnUrl) || !Uri.IsWellFormedUriString(returnUrl, UriKind.Relative)
- ? "/"
- : returnUrl;
-
context.Response.Redirect(safeReturnUrl);
return;
}).DisableAntiforgery();
app.MapPost("/api/logout", async (HttpContext context) =>
{
+ if (!authenticationEnabled)
+ {
+ context.Response.Redirect("/");
+ return;
+ }
+
await context.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
context.Response.Redirect("/login");
return;
@@ -142,6 +174,12 @@ app.MapPost("/api/logout", async (HttpContext context) =>
app.MapPost("/api/change-password", async (HttpContext context) =>
{
+ if (!authenticationEnabled)
+ {
+ context.Response.Redirect("/");
+ return;
+ }
+
var authService = context.RequestServices.GetRequiredService();
if (!context.User?.Identity?.IsAuthenticated ?? true)
{
@@ -159,7 +197,7 @@ app.MapPost("/api/change-password", async (HttpContext context) =>
return;
}
- var userId = context.User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
+ var userId = context.User?.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (string.IsNullOrWhiteSpace(userId))
{
await context.ChallengeAsync();
@@ -177,6 +215,24 @@ app.MapPost("/api/change-password", async (HttpContext context) =>
return;
}).DisableAntiforgery();
+app.MapGet("/healthz", [AllowAnonymous] (HttpContext context, IOptions authOptions, CouchbaseLiteDatabaseProvider databaseProvider) =>
+{
+ return Results.Json(new
+ {
+ status = "Healthy",
+ authenticationEnabled = authOptions.Value.Enabled,
+ currentUser = context.User.Identity?.Name,
+ environment = app.Environment.EnvironmentName,
+ storage = new
+ {
+ appSettingsReady = databaseProvider.AppSettings is not null,
+ usersReady = databaseProvider.Users is not null,
+ workDaysReady = databaseProvider.WorkDays is not null
+ },
+ timestampUtc = DateTimeOffset.UtcNow
+ });
+});
+
// Development-only endpoint to reset the seeded Admin password (protected by secret in URL)
if (app.Environment.IsDevelopment())
{
diff --git a/README.Docker.md b/README.Docker.md
index df5160d..67a7a0d 100644
--- a/README.Docker.md
+++ b/README.Docker.md
@@ -8,6 +8,16 @@ Quick run (Docker Engine required):
2. App will be available on host port 8002 -> container 8080 (http://localhost:8002).
+Authentication mode:
+
+- Authentication is disabled by default and every request runs as the configured default admin user. This is intended for deployments fronted by Cloudflare Zero Trust.
+- Re-enable the built-in login flow by setting `AppAuth__Enabled=true` or changing `AppAuth:Enabled` to `true` in appsettings.json.
+- The default admin identity used while auth is disabled is configured under `AppAuth:DefaultUsername` and `AppAuth:DefaultUserId`.
+
+Health endpoint:
+
+- `GET /healthz` returns JSON health information, including whether built-in authentication is enabled and whether the Couchbase Lite collections initialized correctly.
+
Docker persistence:
- The app now uses an embedded Couchbase Lite database stored under `/data/couchbase` inside the container.
@@ -25,6 +35,14 @@ Development in Docker:
- The override file keeps a containerized `dotnet watch` flow for cases where you still want Docker.
- The development container mounts the workspace into `/workspace` and stores its Couchbase Lite files under `./.docker-data/couchbase-dev` on the host.
+Debugging in Docker from VS Code:
+
+- Use the `WorkTracker: Debug in Docker` launch configuration.
+- VS Code brings up the development container with `docker compose`, builds the app in `Debug`, and launches `WorkTracker.dll` under `vsdbg` inside the container.
+- When the app reports that it is listening, VS Code automatically opens Microsoft Edge in browser debug mode against http://localhost:8002.
+- The app remains available at http://localhost:8002 while the debugger is attached.
+- Stopping the debug session runs `docker compose down` for the debug stack.
+
Manual development start:
- `docker compose up --build`
@@ -33,8 +51,13 @@ Manual shutdown:
- `docker compose down`
+Docker Playwright smoke tests:
+
+- Run `docker compose -f docker-compose.yml -f docker-compose.tests.yml up --build --abort-on-container-exit --exit-code-from playwright playwright`
+- The suite starts the app in Docker, waits for `/healthz`, and verifies that protected pages load without a login screen.
+
Notes:
- The base compose file remains production-oriented; the override file is the optional containerized development layer.
- The first container build takes longer because the dev image installs the .NET debugger.
-- The Dockerfile uses the .NET 9 `*-noble` images so local builds and container builds stay aligned with the SDK available in VS Code.
+- The Dockerfile uses the .NET 10 `*-noble` images so local builds and container builds stay aligned with the SDK available in VS Code.
diff --git a/Services/Auth/DefaultAdminAuthenticationHandler.cs b/Services/Auth/DefaultAdminAuthenticationHandler.cs
new file mode 100644
index 0000000..34bbc63
--- /dev/null
+++ b/Services/Auth/DefaultAdminAuthenticationHandler.cs
@@ -0,0 +1,48 @@
+using System.Security.Claims;
+using System.Text.Encodings.Web;
+using Microsoft.AspNetCore.Authentication;
+using Microsoft.Extensions.Options;
+using WorkTracker.Configuration;
+
+namespace WorkTracker.Services.Auth;
+
+public sealed class DefaultAdminAuthenticationHandler : AuthenticationHandler
+{
+ public const string SchemeName = "DefaultAdmin";
+
+ private readonly IOptions appAuthOptions;
+
+ public DefaultAdminAuthenticationHandler(
+ IOptionsMonitor options,
+ ILoggerFactory logger,
+ UrlEncoder encoder,
+ IOptions appAuthOptions)
+ : base(options, logger, encoder)
+ {
+ this.appAuthOptions = appAuthOptions;
+ }
+
+ protected override Task HandleAuthenticateAsync()
+ {
+ var configuredOptions = appAuthOptions.Value;
+ var username = string.IsNullOrWhiteSpace(configuredOptions.DefaultUsername)
+ ? "Admin"
+ : configuredOptions.DefaultUsername.Trim();
+ var userId = string.IsNullOrWhiteSpace(configuredOptions.DefaultUserId)
+ ? username.ToUpperInvariant()
+ : configuredOptions.DefaultUserId.Trim();
+
+ var claims = new List
+ {
+ new(ClaimTypes.NameIdentifier, userId),
+ new(ClaimTypes.Name, username),
+ new(ClaimTypes.Role, "Admin")
+ };
+
+ var identity = new ClaimsIdentity(claims, SchemeName);
+ var principal = new ClaimsPrincipal(identity);
+ var ticket = new AuthenticationTicket(principal, SchemeName);
+
+ return Task.FromResult(AuthenticateResult.Success(ticket));
+ }
+}
\ No newline at end of file
diff --git a/appsettings.json b/appsettings.json
index 579b485..d6fd259 100644
--- a/appsettings.json
+++ b/appsettings.json
@@ -8,6 +8,11 @@
"Username": "Admin",
"Password": "Disagio"
},
+ "AppAuth": {
+ "Enabled": false,
+ "DefaultUsername": "Admin",
+ "DefaultUserId": "ADMIN"
+ },
"Logging": {
"LogLevel": {
"Default": "Information",
diff --git a/docker-compose.tests.yml b/docker-compose.tests.yml
new file mode 100644
index 0000000..9c4c27a
--- /dev/null
+++ b/docker-compose.tests.yml
@@ -0,0 +1,10 @@
+services:
+ playwright:
+ build:
+ context: .
+ dockerfile: Dockerfile.playwright
+ depends_on:
+ worktracker:
+ condition: service_healthy
+ environment:
+ PLAYWRIGHT_BASE_URL: http://worktracker:8080
\ No newline at end of file
diff --git a/docker-compose.yml b/docker-compose.yml
index 715abe3..08d2020 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -14,7 +14,7 @@ services:
- ${WORKTRACKER_DATA_PATH:-./.docker-data/couchbase}:/data/couchbase
restart: unless-stopped
healthcheck:
- test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8080/ >/dev/null 2>&1 || exit 1"]
+ test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8080/healthz >/dev/null 2>&1 || exit 1"]
interval: 30s
timeout: 5s
retries: 3
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..64ec29f
--- /dev/null
+++ b/package.json
@@ -0,0 +1,10 @@
+{
+ "name": "worktracker-playwright",
+ "private": true,
+ "scripts": {
+ "test:e2e": "playwright test"
+ },
+ "devDependencies": {
+ "@playwright/test": "1.59.1"
+ }
+}
\ No newline at end of file
diff --git a/playwright.config.ts b/playwright.config.ts
new file mode 100644
index 0000000..df4430c
--- /dev/null
+++ b/playwright.config.ts
@@ -0,0 +1,15 @@
+import { defineConfig } from '@playwright/test';
+
+const baseURL = process.env.PLAYWRIGHT_BASE_URL ?? 'http://127.0.0.1:8002';
+
+export default defineConfig({
+ testDir: './tests/playwright',
+ timeout: 30_000,
+ fullyParallel: true,
+ retries: 0,
+ use: {
+ baseURL,
+ trace: 'retain-on-failure'
+ },
+ reporter: [['list']]
+});
\ No newline at end of file
diff --git a/tests/playwright/auth-bypass.spec.ts b/tests/playwright/auth-bypass.spec.ts
new file mode 100644
index 0000000..5250760
--- /dev/null
+++ b/tests/playwright/auth-bypass.spec.ts
@@ -0,0 +1,33 @@
+import { expect, test } from '@playwright/test';
+
+test('home loads without a login screen', async ({ page }) => {
+ await page.goto('/');
+
+ await expect(page).toHaveURL(/\/$/);
+ await expect(page.getByRole('heading', { name: 'WorkTracker' })).toBeVisible();
+ await expect(page.getByRole('heading', { name: 'Login' })).toHaveCount(0);
+});
+
+test('protected pages are directly available without redirecting to login', async ({ page }) => {
+ const pages = [
+ { path: '/grid', heading: 'Grid View' },
+ { path: '/calendar', heading: 'Calendar' },
+ { path: '/summary', heading: 'Monthly Summary' },
+ { path: '/settings', heading: 'Settings' },
+ { path: '/workday', heading: 'Work Day Entry' },
+ { path: '/auth', heading: 'You are authenticated' }
+ ];
+
+ for (const target of pages) {
+ await page.goto(target.path);
+ await expect(page).not.toHaveURL(/\/login/i);
+ await expect(page.getByRole('heading', { name: target.heading })).toBeVisible();
+ }
+});
+
+test('login route redirects away when built-in authentication is disabled', async ({ page }) => {
+ await page.goto('/login');
+
+ await expect(page).toHaveURL(/\/$/);
+ await expect(page.getByRole('heading', { name: 'WorkTracker' })).toBeVisible();
+});
\ No newline at end of file
diff --git a/tests/playwright/health.spec.ts b/tests/playwright/health.spec.ts
new file mode 100644
index 0000000..8b7f579
--- /dev/null
+++ b/tests/playwright/health.spec.ts
@@ -0,0 +1,21 @@
+import { expect, test } from '@playwright/test';
+
+test('health endpoint reports healthy zero-trust mode', async ({ request }) => {
+ const response = await request.get('/healthz');
+
+ expect(response.ok()).toBeTruthy();
+
+ const payload = await response.json();
+
+ expect(payload.status).toBe('Healthy');
+ expect(payload.authenticationEnabled).toBe(false);
+ expect(payload.storage.appSettingsReady).toBe(true);
+ expect(payload.storage.usersReady).toBe(true);
+ expect(payload.storage.workDaysReady).toBe(true);
+});
+
+test('authenticated identity is the default admin user', async ({ page }) => {
+ await page.goto('/auth');
+
+ await expect(page.getByText('Hello Admin!')).toBeVisible();
+});
\ No newline at end of file