Skip to content

Commit 208c657

Browse files
committed
Fix MfaCredentialTests and resolve merge conflicts with main
Update SetRecoveryCodes test to match domain method: empty/null now clears codes instead of throwing (supports exhausted recovery codes). Remove AuthenticationRegistrationTests (belongs to PR #813, not cache PR). Take main's versions for non-cache files.
2 parents 5940167 + 888db4c commit 208c657

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

41 files changed

+3656
-153
lines changed

.github/workflows/ci-extended.yml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,16 @@ jobs:
107107
dotnet-version: 8.0.x
108108
node-version: 24.13.1
109109

110+
e2e-cross-browser:
111+
name: E2E Cross-Browser Matrix
112+
if: github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'testing'))
113+
needs:
114+
- backend-solution
115+
uses: ./.github/workflows/reusable-e2e-cross-browser.yml
116+
with:
117+
dotnet-version: 8.0.x
118+
node-version: 24.13.1
119+
110120
visual-regression:
111121
name: Visual Regression
112122
if: github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && (contains(github.event.pull_request.labels.*.name, 'testing') || contains(github.event.pull_request.labels.*.name, 'visual')))

.github/workflows/ci-nightly.yml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,15 @@ jobs:
6060
k6-duration: "90s"
6161
k6-user-pool: "6"
6262

63+
e2e-cross-browser:
64+
name: E2E Cross-Browser Matrix
65+
needs:
66+
- backend-solution
67+
uses: ./.github/workflows/reusable-e2e-cross-browser.yml
68+
with:
69+
dotnet-version: 8.0.x
70+
node-version: 24.13.1
71+
6372
container-images:
6473
name: Container Images Regression
6574
needs:

.github/workflows/ci-required.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
# ├── reusable-openapi-guardrail.yml
2121
# ├── reusable-backend-solution.yml (label: testing)
2222
# ├── reusable-e2e-smoke.yml (label: testing)
23+
# ├── reusable-e2e-cross-browser.yml (label: testing)
2324
# ├── reusable-demo-director-smoke.yml (label: automation)
2425
# ├── reusable-load-concurrency-harness.yml (label: testing)
2526
# └── reusable-container-integration.yml (label: testing) — Testcontainers PostgreSQL
@@ -28,6 +29,7 @@
2829
# ├── reusable-openapi-guardrail.yml
2930
# ├── reusable-backend-solution.yml
3031
# ├── reusable-e2e-smoke.yml
32+
# ├── reusable-e2e-cross-browser.yml
3133
# ├── reusable-load-concurrency-harness.yml
3234
# └── reusable-container-images.yml
3335
#
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
name: Reusable E2E Cross-Browser Matrix
2+
3+
on:
4+
workflow_call:
5+
inputs:
6+
dotnet-version:
7+
description: .NET SDK version used for E2E backend setup
8+
required: false
9+
default: "8.0.x"
10+
type: string
11+
node-version:
12+
description: Node.js version used for E2E frontend setup
13+
required: false
14+
default: "24.13.1"
15+
type: string
16+
17+
permissions:
18+
contents: read
19+
20+
env:
21+
NUGET_PACKAGES: ${{ github.workspace }}/.nuget/packages
22+
23+
jobs:
24+
e2e-cross-browser:
25+
name: E2E (${{ matrix.project }})
26+
runs-on: ubuntu-latest
27+
timeout-minutes: 30
28+
strategy:
29+
fail-fast: false
30+
matrix:
31+
include:
32+
- project: chromium
33+
browser: chromium
34+
- project: firefox
35+
browser: firefox
36+
- project: webkit
37+
browser: webkit
38+
- project: mobile-chrome
39+
browser: chromium
40+
- project: mobile-safari
41+
browser: webkit
42+
steps:
43+
- name: Checkout
44+
uses: actions/checkout@v6
45+
46+
- name: Setup .NET
47+
uses: actions/setup-dotnet@v5
48+
with:
49+
dotnet-version: ${{ inputs.dotnet-version }}
50+
cache: true
51+
cache-dependency-path: |
52+
backend/Taskdeck.sln
53+
backend/**/*.csproj
54+
55+
- name: Setup Node
56+
uses: actions/setup-node@v6
57+
with:
58+
node-version: ${{ inputs.node-version }}
59+
cache: npm
60+
cache-dependency-path: frontend/taskdeck-web/package-lock.json
61+
62+
- name: Restore backend
63+
run: dotnet restore backend/Taskdeck.sln
64+
65+
- name: Install frontend dependencies
66+
working-directory: frontend/taskdeck-web
67+
run: npm ci
68+
69+
- name: Cache Playwright browsers
70+
uses: actions/cache@v5
71+
with:
72+
path: ~/.cache/ms-playwright
73+
key: ms-playwright-${{ runner.os }}-${{ hashFiles('frontend/taskdeck-web/package-lock.json') }}
74+
75+
- name: Install Playwright browsers
76+
working-directory: frontend/taskdeck-web
77+
run: npx playwright install --with-deps ${{ matrix.browser }}
78+
79+
- name: Remove stale E2E database
80+
working-directory: frontend/taskdeck-web
81+
run: node -e "require('fs').rmSync('taskdeck.e2e.ci.db',{force:true});"
82+
83+
- name: Run Playwright tests (${{ matrix.project }})
84+
timeout-minutes: 15
85+
working-directory: frontend/taskdeck-web
86+
env:
87+
CI: "true"
88+
TASKDECK_E2E_DB: taskdeck.e2e.ci.db
89+
TASKDECK_RUN_DEMO: "0"
90+
run: npx playwright test --project=${{ matrix.project }} --reporter=line
91+
92+
- name: Upload Playwright report
93+
if: failure()
94+
uses: actions/upload-artifact@v7
95+
with:
96+
name: playwright-report-${{ matrix.project }}
97+
path: frontend/taskdeck-web/playwright-report
98+
if-no-files-found: ignore
99+
100+
- name: Upload Playwright test results
101+
if: failure()
102+
uses: actions/upload-artifact@v7
103+
with:
104+
name: playwright-test-results-${{ matrix.project }}
105+
path: frontend/taskdeck-web/test-results
106+
if-no-files-found: ignore

.github/workflows/reusable-e2e-smoke.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ jobs:
7373
CI: "true"
7474
TASKDECK_E2E_DB: taskdeck.e2e.ci.db
7575
TASKDECK_RUN_DEMO: "0"
76-
run: npx playwright test --reporter=line
76+
run: npx playwright test --project=chromium --reporter=line
7777

7878
- name: Upload Playwright report
7979
if: failure()
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
using Microsoft.AspNetCore.Authorization;
2+
using Microsoft.AspNetCore.Mvc;
3+
using Taskdeck.Application.Services;
4+
5+
namespace Taskdeck.Api.Controllers;
6+
7+
/// <summary>
8+
/// Endpoints for opt-in product telemetry event recording and client configuration.
9+
/// All telemetry is disabled by default and requires explicit opt-in.
10+
/// </summary>
11+
[ApiController]
12+
[Route("api/telemetry")]
13+
[Authorize]
14+
public class TelemetryController : ControllerBase
15+
{
16+
private readonly ITelemetryEventService _telemetryEventService;
17+
private readonly SentrySettings _sentrySettings;
18+
private readonly AnalyticsSettings _analyticsSettings;
19+
private readonly TelemetrySettings _telemetrySettings;
20+
21+
public TelemetryController(
22+
ITelemetryEventService telemetryEventService,
23+
SentrySettings sentrySettings,
24+
AnalyticsSettings analyticsSettings,
25+
TelemetrySettings telemetrySettings)
26+
{
27+
_telemetryEventService = telemetryEventService;
28+
_sentrySettings = sentrySettings;
29+
_analyticsSettings = analyticsSettings;
30+
_telemetrySettings = telemetrySettings;
31+
}
32+
33+
/// <summary>
34+
/// Returns client-side telemetry configuration. The frontend uses this to
35+
/// determine which integrations are available and how to initialize them.
36+
/// DSNs and script URLs are only returned when the corresponding integration
37+
/// is enabled. No secrets or API keys are exposed.
38+
/// </summary>
39+
[HttpGet("config")]
40+
[AllowAnonymous]
41+
public IActionResult GetConfig()
42+
{
43+
return Ok(new ClientTelemetryConfigResponse
44+
{
45+
Sentry = new SentryClientConfig
46+
{
47+
Enabled = _sentrySettings.Enabled,
48+
Dsn = _sentrySettings.Enabled ? _sentrySettings.Dsn : string.Empty,
49+
Environment = _sentrySettings.Environment,
50+
TracesSampleRate = _sentrySettings.TracesSampleRate,
51+
},
52+
Analytics = new AnalyticsClientConfig
53+
{
54+
Enabled = _analyticsSettings.Enabled,
55+
Provider = _analyticsSettings.Enabled ? _analyticsSettings.Provider : string.Empty,
56+
ScriptUrl = _analyticsSettings.Enabled ? _analyticsSettings.ScriptUrl : string.Empty,
57+
SiteId = _analyticsSettings.Enabled ? _analyticsSettings.SiteId : string.Empty,
58+
},
59+
Telemetry = new TelemetryClientConfig
60+
{
61+
Enabled = _telemetrySettings.Enabled,
62+
},
63+
});
64+
}
65+
66+
/// <summary>
67+
/// Records a batch of product telemetry events. Requires authentication.
68+
/// Events are validated against the taxonomy naming convention and rejected
69+
/// if telemetry is disabled on the server.
70+
/// </summary>
71+
[HttpPost("events")]
72+
public IActionResult RecordEvents([FromBody] TelemetryBatchRequest? request)
73+
{
74+
if (!_telemetryEventService.IsEnabled)
75+
{
76+
return Ok(new { recorded = 0, message = "Telemetry is disabled on this server." });
77+
}
78+
79+
if (request == null || request.Events == null || request.Events.Count == 0)
80+
{
81+
return BadRequest(new { error = "No events provided." });
82+
}
83+
84+
var recorded = _telemetryEventService.RecordEvents(request.Events);
85+
return Ok(new { recorded });
86+
}
87+
}
88+
89+
public sealed class ClientTelemetryConfigResponse
90+
{
91+
public SentryClientConfig Sentry { get; set; } = new();
92+
public AnalyticsClientConfig Analytics { get; set; } = new();
93+
public TelemetryClientConfig Telemetry { get; set; } = new();
94+
}
95+
96+
public sealed class SentryClientConfig
97+
{
98+
public bool Enabled { get; set; }
99+
public string Dsn { get; set; } = string.Empty;
100+
public string Environment { get; set; } = string.Empty;
101+
public double TracesSampleRate { get; set; }
102+
}
103+
104+
public sealed class AnalyticsClientConfig
105+
{
106+
public bool Enabled { get; set; }
107+
public string Provider { get; set; } = string.Empty;
108+
public string ScriptUrl { get; set; } = string.Empty;
109+
public string SiteId { get; set; } = string.Empty;
110+
}
111+
112+
public sealed class TelemetryClientConfig
113+
{
114+
public bool Enabled { get; set; }
115+
}
116+
117+
public sealed class TelemetryBatchRequest
118+
{
119+
public List<TelemetryEvent> Events { get; set; } = new();
120+
}

backend/src/Taskdeck.Api/Extensions/AuthenticationRegistration.cs

Lines changed: 2 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,8 @@
11
using System.Security.Claims;
22
using System.Text;
33
using Microsoft.AspNetCore.Authentication;
4-
using Microsoft.AspNetCore.Authentication.Cookies;
54
using Microsoft.AspNetCore.Authentication.JwtBearer;
65
using Microsoft.AspNetCore.Authentication.OAuth.Claims;
7-
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
8-
using Microsoft.AspNetCore.Http;
96
using Microsoft.IdentityModel.Tokens;
107
using Taskdeck.Api.Contracts;
118
using Taskdeck.Application.Services;
@@ -16,16 +13,10 @@ namespace Taskdeck.Api.Extensions;
1613

1714
public static class AuthenticationRegistration
1815
{
19-
/// <summary>
20-
/// Cookie scheme used for temporary external auth state (OAuth/OIDC handshake).
21-
/// </summary>
22-
public const string ExternalAuthenticationScheme = "External";
23-
2416
public static IServiceCollection AddTaskdeckAuthentication(
2517
this IServiceCollection services,
2618
JwtSettings jwtSettings,
27-
GitHubOAuthSettings? gitHubOAuthSettings = null,
28-
OidcSettings? oidcSettings = null)
19+
GitHubOAuthSettings? gitHubOAuthSettings = null)
2920
{
3021
if (string.IsNullOrWhiteSpace(jwtSettings.SecretKey) ||
3122
jwtSettings.SecretKey.Length < 32 ||
@@ -35,21 +26,7 @@ public static IServiceCollection AddTaskdeckAuthentication(
3526
return services;
3627
}
3728

38-
var authBuilder = services.AddAuthentication(options =>
39-
{
40-
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
41-
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
42-
options.DefaultSignInScheme = ExternalAuthenticationScheme;
43-
})
44-
.AddCookie(ExternalAuthenticationScheme, options =>
45-
{
46-
options.Cookie.Name = ".Taskdeck.ExternalAuth";
47-
options.Cookie.HttpOnly = true;
48-
options.Cookie.SameSite = SameSiteMode.Lax;
49-
options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest;
50-
options.ExpireTimeSpan = TimeSpan.FromMinutes(5);
51-
options.SlidingExpiration = false;
52-
})
29+
var authBuilder = services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
5330
.AddJwtBearer(options =>
5431
{
5532
options.TokenValidationParameters = new TokenValidationParameters
@@ -113,7 +90,6 @@ await context.Response.WriteAsJsonAsync(new ApiErrorResponse(
11390
{
11491
authBuilder.AddOAuth("GitHub", options =>
11592
{
116-
options.SignInScheme = ExternalAuthenticationScheme;
11793
options.ClientId = gitHubOAuthSettings.ClientId;
11894
options.ClientSecret = gitHubOAuthSettings.ClientSecret;
11995
options.AuthorizationEndpoint = "https://github.com/login/oauth/authorize";
@@ -142,42 +118,6 @@ await context.Response.WriteAsJsonAsync(new ApiErrorResponse(
142118
});
143119
}
144120

145-
// Environment-gated: add OIDC providers when configured
146-
if (oidcSettings != null)
147-
{
148-
foreach (var provider in oidcSettings.ConfiguredProviders)
149-
{
150-
var schemeName = $"Oidc_{provider.Name}";
151-
var callbackPath = !string.IsNullOrWhiteSpace(provider.CallbackPath)
152-
? provider.CallbackPath
153-
: $"/api/auth/oidc/{provider.Name.ToLowerInvariant()}/oauth-redirect";
154-
155-
authBuilder.AddOpenIdConnect(schemeName, provider.DisplayName, options =>
156-
{
157-
options.SignInScheme = ExternalAuthenticationScheme;
158-
options.Authority = provider.Authority;
159-
options.ClientId = provider.ClientId;
160-
options.ClientSecret = provider.ClientSecret;
161-
options.CallbackPath = callbackPath;
162-
options.ResponseType = "code";
163-
options.SaveTokens = false;
164-
options.GetClaimsFromUserInfoEndpoint = true;
165-
166-
options.Scope.Clear();
167-
foreach (var scope in provider.Scopes)
168-
{
169-
options.Scope.Add(scope);
170-
}
171-
172-
// Map standard OIDC claims to ClaimTypes
173-
options.ClaimActions.MapJsonKey(ClaimTypes.NameIdentifier, "sub");
174-
options.ClaimActions.MapJsonKey(ClaimTypes.Name, "preferred_username");
175-
options.ClaimActions.MapJsonKey(ClaimTypes.Email, "email");
176-
options.ClaimActions.MapJsonKey("name", "name");
177-
});
178-
}
179-
}
180-
181121
return services;
182122
}
183123

0 commit comments

Comments
 (0)