This commit is contained in:
parent
325e2f1ee9
commit
08e573d63c
17 changed files with 348 additions and 26 deletions
40
.vscode/tasks.json
vendored
40
.vscode/tasks.json
vendored
|
|
@ -68,6 +68,46 @@
|
||||||
"cwd": "${workspaceFolder}"
|
"cwd": "${workspaceFolder}"
|
||||||
},
|
},
|
||||||
"problemMatcher": []
|
"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": []
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -1,4 +1,8 @@
|
||||||
<div class="top-row ps-3 navbar navbar-dark">
|
@using Microsoft.Extensions.Options
|
||||||
|
@using WorkTracker.Configuration
|
||||||
|
@inject IOptions<AppAuthOptions> AppAuthOptions
|
||||||
|
|
||||||
|
<div class="top-row ps-3 navbar navbar-dark">
|
||||||
<div class="container-fluid">
|
<div class="container-fluid">
|
||||||
<a class="navbar-brand" href="">WorkTracker</a>
|
<a class="navbar-brand" href="">WorkTracker</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -51,6 +55,8 @@
|
||||||
<span class="bi bi-person-fill-nav-menu" aria-hidden="true"></span> @context.User.Identity?.Name
|
<span class="bi bi-person-fill-nav-menu" aria-hidden="true"></span> @context.User.Identity?.Name
|
||||||
</NavLink>
|
</NavLink>
|
||||||
</div>
|
</div>
|
||||||
|
@if (AppAuthOptions.Value.Enabled)
|
||||||
|
{
|
||||||
<div class="nav-item px-3">
|
<div class="nav-item px-3">
|
||||||
<form action="/api/logout" method="post">
|
<form action="/api/logout" method="post">
|
||||||
<button type="submit" class="nav-link">
|
<button type="submit" class="nav-link">
|
||||||
|
|
@ -58,6 +64,7 @@
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
}
|
||||||
</Authorized>
|
</Authorized>
|
||||||
<NotAuthorized>
|
<NotAuthorized>
|
||||||
<div class="nav-item px-3">
|
<div class="nav-item px-3">
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,20 @@
|
||||||
|
|
||||||
@using Microsoft.AspNetCore.Authorization
|
@using Microsoft.AspNetCore.Authorization
|
||||||
@using Microsoft.AspNetCore.Components
|
@using Microsoft.AspNetCore.Components
|
||||||
|
@using Microsoft.Extensions.Options
|
||||||
|
@using WorkTracker.Configuration
|
||||||
|
|
||||||
|
@inject IOptions<AppAuthOptions> AppAuthOptions
|
||||||
|
@inject NavigationManager Navigation
|
||||||
|
|
||||||
<PageTitle>Change Password</PageTitle>
|
<PageTitle>Change Password</PageTitle>
|
||||||
|
|
||||||
|
@if (!AppAuthOptions.Value.Enabled)
|
||||||
|
{
|
||||||
|
<p>Redirecting...</p>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
<h1>Change password</h1>
|
<h1>Change password</h1>
|
||||||
|
|
||||||
@if (!string.IsNullOrWhiteSpace(Error))
|
@if (!string.IsNullOrWhiteSpace(Error))
|
||||||
|
|
@ -26,8 +37,17 @@
|
||||||
|
|
||||||
<button type="submit" class="btn btn-primary">Change password</button>
|
<button type="submit" class="btn btn-primary">Change password</button>
|
||||||
</form>
|
</form>
|
||||||
|
}
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
[SupplyParameterFromQuery]
|
[SupplyParameterFromQuery]
|
||||||
public string? Error { get; set; }
|
public string? Error { get; set; }
|
||||||
|
|
||||||
|
protected override void OnInitialized()
|
||||||
|
{
|
||||||
|
if (!AppAuthOptions.Value.Enabled)
|
||||||
|
{
|
||||||
|
Navigation.NavigateTo("/", forceLoad: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,20 @@
|
||||||
|
|
||||||
@using Microsoft.AspNetCore.Authorization
|
@using Microsoft.AspNetCore.Authorization
|
||||||
@using Microsoft.AspNetCore.Components
|
@using Microsoft.AspNetCore.Components
|
||||||
|
@using Microsoft.Extensions.Options
|
||||||
|
@using WorkTracker.Configuration
|
||||||
|
|
||||||
|
@inject IOptions<AppAuthOptions> AppAuthOptions
|
||||||
|
@inject NavigationManager Navigation
|
||||||
|
|
||||||
<PageTitle>Login</PageTitle>
|
<PageTitle>Login</PageTitle>
|
||||||
|
|
||||||
|
@if (!AppAuthOptions.Value.Enabled)
|
||||||
|
{
|
||||||
|
<p>Redirecting...</p>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
<h1>Login</h1>
|
<h1>Login</h1>
|
||||||
|
|
||||||
@if (!string.IsNullOrWhiteSpace(Error))
|
@if (!string.IsNullOrWhiteSpace(Error))
|
||||||
|
|
@ -28,6 +39,7 @@
|
||||||
|
|
||||||
<button type="submit" class="btn btn-primary">Sign in</button>
|
<button type="submit" class="btn btn-primary">Sign in</button>
|
||||||
</form>
|
</form>
|
||||||
|
}
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
[SupplyParameterFromQuery]
|
[SupplyParameterFromQuery]
|
||||||
|
|
@ -39,6 +51,14 @@
|
||||||
[SupplyParameterFromQuery]
|
[SupplyParameterFromQuery]
|
||||||
public string? Username { get; set; }
|
public string? Username { get; set; }
|
||||||
|
|
||||||
|
protected override void OnInitialized()
|
||||||
|
{
|
||||||
|
if (!AppAuthOptions.Value.Enabled)
|
||||||
|
{
|
||||||
|
Navigation.NavigateTo(SafeReturnUrl, forceLoad: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private string SafeReturnUrl =>
|
private string SafeReturnUrl =>
|
||||||
string.IsNullOrWhiteSpace(ReturnUrl) || !Uri.IsWellFormedUriString(ReturnUrl, UriKind.Relative)
|
string.IsNullOrWhiteSpace(ReturnUrl) || !Uri.IsWellFormedUriString(ReturnUrl, UriKind.Relative)
|
||||||
? "/"
|
? "/"
|
||||||
|
|
|
||||||
12
Configuration/AppAuthOptions.cs
Normal file
12
Configuration/AppAuthOptions.cs
Normal file
|
|
@ -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";
|
||||||
|
}
|
||||||
|
|
@ -36,6 +36,6 @@ EXPOSE 8080
|
||||||
VOLUME ["/data/couchbase"]
|
VOLUME ["/data/couchbase"]
|
||||||
|
|
||||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
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"]
|
ENTRYPOINT ["dotnet", "WorkTracker.dll"]
|
||||||
|
|
|
||||||
10
Dockerfile.playwright
Normal file
10
Dockerfile.playwright
Normal file
|
|
@ -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"]
|
||||||
72
Program.cs
72
Program.cs
|
|
@ -1,6 +1,7 @@
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using NLog.Web;
|
using NLog.Web;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Authentication;
|
using Microsoft.AspNetCore.Authentication;
|
||||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||||
using Microsoft.AspNetCore.Components.Authorization;
|
using Microsoft.AspNetCore.Components.Authorization;
|
||||||
|
|
@ -25,10 +26,16 @@ builder.Host.UseNLog();
|
||||||
builder.Services.AddRazorComponents()
|
builder.Services.AddRazorComponents()
|
||||||
.AddInteractiveServerComponents();
|
.AddInteractiveServerComponents();
|
||||||
|
|
||||||
|
builder.Services.Configure<AppAuthOptions>(builder.Configuration.GetSection(AppAuthOptions.SectionName));
|
||||||
|
var appAuthOptions = builder.Configuration.GetSection(AppAuthOptions.SectionName).Get<AppAuthOptions>() ?? new AppAuthOptions();
|
||||||
|
var authenticationEnabled = appAuthOptions.Enabled;
|
||||||
|
|
||||||
builder.Services.AddCascadingAuthenticationState();
|
builder.Services.AddCascadingAuthenticationState();
|
||||||
builder.Services.AddAuthorization();
|
builder.Services.AddAuthorization();
|
||||||
|
|
||||||
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
|
if (authenticationEnabled)
|
||||||
|
{
|
||||||
|
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
|
||||||
.AddCookie(options =>
|
.AddCookie(options =>
|
||||||
{
|
{
|
||||||
options.LoginPath = "/login";
|
options.LoginPath = "/login";
|
||||||
|
|
@ -37,6 +44,14 @@ builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationSc
|
||||||
options.SlidingExpiration = true;
|
options.SlidingExpiration = true;
|
||||||
options.ExpireTimeSpan = TimeSpan.FromDays(14);
|
options.ExpireTimeSpan = TimeSpan.FromDays(14);
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
builder.Services.AddAuthentication(DefaultAdminAuthenticationHandler.SchemeName)
|
||||||
|
.AddScheme<AuthenticationSchemeOptions, DefaultAdminAuthenticationHandler>(
|
||||||
|
DefaultAdminAuthenticationHandler.SchemeName,
|
||||||
|
static _ => { });
|
||||||
|
}
|
||||||
|
|
||||||
builder.Services.AddLocalization();
|
builder.Services.AddLocalization();
|
||||||
|
|
||||||
|
|
@ -86,14 +101,29 @@ app.UseAuthorization();
|
||||||
|
|
||||||
app.UseAntiforgery();
|
app.UseAntiforgery();
|
||||||
|
|
||||||
|
static string GetSafeReturnUrl(string? returnUrl)
|
||||||
|
{
|
||||||
|
return string.IsNullOrWhiteSpace(returnUrl) || !Uri.IsWellFormedUriString(returnUrl, UriKind.Relative)
|
||||||
|
? "/"
|
||||||
|
: returnUrl;
|
||||||
|
}
|
||||||
|
|
||||||
app.MapPost("/api/login", async (HttpContext context) =>
|
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<IAuthService>();
|
var authService = context.RequestServices.GetRequiredService<IAuthService>();
|
||||||
var logger = context.RequestServices.GetRequiredService<ILoggerFactory>().CreateLogger("Auth.Login");
|
var logger = context.RequestServices.GetRequiredService<ILoggerFactory>().CreateLogger("Auth.Login");
|
||||||
var form = await context.Request.ReadFormAsync();
|
|
||||||
var username = form["username"].ToString();
|
var username = form["username"].ToString();
|
||||||
var password = form["password"].ToString();
|
var password = form["password"].ToString();
|
||||||
var returnUrl = form["returnUrl"].ToString();
|
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
|
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
|
||||||
{
|
{
|
||||||
|
|
@ -125,16 +155,18 @@ app.MapPost("/api/login", async (HttpContext context) =>
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var safeReturnUrl = string.IsNullOrWhiteSpace(returnUrl) || !Uri.IsWellFormedUriString(returnUrl, UriKind.Relative)
|
|
||||||
? "/"
|
|
||||||
: returnUrl;
|
|
||||||
|
|
||||||
context.Response.Redirect(safeReturnUrl);
|
context.Response.Redirect(safeReturnUrl);
|
||||||
return;
|
return;
|
||||||
}).DisableAntiforgery();
|
}).DisableAntiforgery();
|
||||||
|
|
||||||
app.MapPost("/api/logout", async (HttpContext context) =>
|
app.MapPost("/api/logout", async (HttpContext context) =>
|
||||||
{
|
{
|
||||||
|
if (!authenticationEnabled)
|
||||||
|
{
|
||||||
|
context.Response.Redirect("/");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await context.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
|
await context.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
|
||||||
context.Response.Redirect("/login");
|
context.Response.Redirect("/login");
|
||||||
return;
|
return;
|
||||||
|
|
@ -142,6 +174,12 @@ app.MapPost("/api/logout", async (HttpContext context) =>
|
||||||
|
|
||||||
app.MapPost("/api/change-password", async (HttpContext context) =>
|
app.MapPost("/api/change-password", async (HttpContext context) =>
|
||||||
{
|
{
|
||||||
|
if (!authenticationEnabled)
|
||||||
|
{
|
||||||
|
context.Response.Redirect("/");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
var authService = context.RequestServices.GetRequiredService<IAuthService>();
|
var authService = context.RequestServices.GetRequiredService<IAuthService>();
|
||||||
if (!context.User?.Identity?.IsAuthenticated ?? true)
|
if (!context.User?.Identity?.IsAuthenticated ?? true)
|
||||||
{
|
{
|
||||||
|
|
@ -159,7 +197,7 @@ app.MapPost("/api/change-password", async (HttpContext context) =>
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var userId = context.User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
var userId = context.User?.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||||
if (string.IsNullOrWhiteSpace(userId))
|
if (string.IsNullOrWhiteSpace(userId))
|
||||||
{
|
{
|
||||||
await context.ChallengeAsync();
|
await context.ChallengeAsync();
|
||||||
|
|
@ -177,6 +215,24 @@ app.MapPost("/api/change-password", async (HttpContext context) =>
|
||||||
return;
|
return;
|
||||||
}).DisableAntiforgery();
|
}).DisableAntiforgery();
|
||||||
|
|
||||||
|
app.MapGet("/healthz", [AllowAnonymous] (HttpContext context, IOptions<AppAuthOptions> 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)
|
// Development-only endpoint to reset the seeded Admin password (protected by secret in URL)
|
||||||
if (app.Environment.IsDevelopment())
|
if (app.Environment.IsDevelopment())
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,16 @@ Quick run (Docker Engine required):
|
||||||
|
|
||||||
2. App will be available on host port 8002 -> container 8080 (http://localhost:8002).
|
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:
|
Docker persistence:
|
||||||
|
|
||||||
- The app now uses an embedded Couchbase Lite database stored under `/data/couchbase` inside the container.
|
- The app now uses an embedded Couchbase Lite database stored under `/data/couchbase` inside the container.
|
||||||
|
|
@ -41,6 +51,11 @@ Manual shutdown:
|
||||||
|
|
||||||
- `docker compose down`
|
- `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:
|
Notes:
|
||||||
|
|
||||||
- The base compose file remains production-oriented; the override file is the optional containerized development layer.
|
- The base compose file remains production-oriented; the override file is the optional containerized development layer.
|
||||||
|
|
|
||||||
48
Services/Auth/DefaultAdminAuthenticationHandler.cs
Normal file
48
Services/Auth/DefaultAdminAuthenticationHandler.cs
Normal file
|
|
@ -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<AuthenticationSchemeOptions>
|
||||||
|
{
|
||||||
|
public const string SchemeName = "DefaultAdmin";
|
||||||
|
|
||||||
|
private readonly IOptions<AppAuthOptions> appAuthOptions;
|
||||||
|
|
||||||
|
public DefaultAdminAuthenticationHandler(
|
||||||
|
IOptionsMonitor<AuthenticationSchemeOptions> options,
|
||||||
|
ILoggerFactory logger,
|
||||||
|
UrlEncoder encoder,
|
||||||
|
IOptions<AppAuthOptions> appAuthOptions)
|
||||||
|
: base(options, logger, encoder)
|
||||||
|
{
|
||||||
|
this.appAuthOptions = appAuthOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override Task<AuthenticateResult> 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<Claim>
|
||||||
|
{
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -8,6 +8,11 @@
|
||||||
"Username": "Admin",
|
"Username": "Admin",
|
||||||
"Password": "Disagio"
|
"Password": "Disagio"
|
||||||
},
|
},
|
||||||
|
"AppAuth": {
|
||||||
|
"Enabled": false,
|
||||||
|
"DefaultUsername": "Admin",
|
||||||
|
"DefaultUserId": "ADMIN"
|
||||||
|
},
|
||||||
"Logging": {
|
"Logging": {
|
||||||
"LogLevel": {
|
"LogLevel": {
|
||||||
"Default": "Information",
|
"Default": "Information",
|
||||||
|
|
|
||||||
10
docker-compose.tests.yml
Normal file
10
docker-compose.tests.yml
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
services:
|
||||||
|
playwright:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.playwright
|
||||||
|
depends_on:
|
||||||
|
worktracker:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
PLAYWRIGHT_BASE_URL: http://worktracker:8080
|
||||||
|
|
@ -14,7 +14,7 @@ services:
|
||||||
- ${WORKTRACKER_DATA_PATH:-./.docker-data/couchbase}:/data/couchbase
|
- ${WORKTRACKER_DATA_PATH:-./.docker-data/couchbase}:/data/couchbase
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
healthcheck:
|
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
|
interval: 30s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
|
|
||||||
10
package.json
Normal file
10
package.json
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
{
|
||||||
|
"name": "worktracker-playwright",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"test:e2e": "playwright test"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@playwright/test": "1.59.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
15
playwright.config.ts
Normal file
15
playwright.config.ts
Normal file
|
|
@ -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']]
|
||||||
|
});
|
||||||
33
tests/playwright/auth-bypass.spec.ts
Normal file
33
tests/playwright/auth-bypass.spec.ts
Normal file
|
|
@ -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();
|
||||||
|
});
|
||||||
21
tests/playwright/health.spec.ts
Normal file
21
tests/playwright/health.spec.ts
Normal file
|
|
@ -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();
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue