Skip to content
Merged
Show file tree
Hide file tree
Changes from 54 commits
Commits
Show all changes
58 commits
Select commit Hold shift + click to select a range
98cae26
Fix CardStatus integer-to-string mapping to match backend enum values
Mar 24, 2026
52cb693
Move CardSelection component to features/cards
Mar 24, 2026
ac71d15
Add stubbed RequestCardReplacement command, handler, and tests
Mar 24, 2026
367ba8f
Add POST /api/household/cards/replace endpoint and controller tests
Mar 24, 2026
822c5d9
Add card replacement API client hook and request schema
Mar 24, 2026
c0b93de
Add ConfirmRequest pre-submission review component
Mar 24, 2026
3eb01e4
Wire CardSelection to ConfirmRequest with sibling auto-select
Mar 24, 2026
c06753c
Add standalone card replacement flow with ConfirmAddress and routes
Mar 24, 2026
414b3b9
Add co-loaded card info route reusing existing CoLoadedInfo component
Mar 24, 2026
9202bdb
Add replacement card links to ChildCard and update ActionButtons CTA
Mar 24, 2026
83a6ad5
Add card replacement success alert to DashboardAlerts with PII-safe f…
Mar 24, 2026
ca946d5
Add 2-week cooldown utility and wire into ChildCard and CardSelection
Mar 24, 2026
825289c
Seed MockHouseholdRepository Faker for stable application numbers
Mar 24, 2026
3ec57b6
Fix mock data instability and remove co-loaded Continue button
Mar 24, 2026
dc22564
Set IssuanceType on mock household applications for realistic card di…
Mar 24, 2026
42bc374
Replace FIS phone content with DHS EBT Card Office locations in CoLoa…
Mar 24, 2026
3d89261
Show SEBT ID in ChildCard and fix cardTableActionRequestReplacement key
Mar 24, 2026
9ffc67a
Remove counters-sm class so timeline step labels render
Mar 24, 2026
113e647
Add dashboard navigation link to card info page alert
Mar 24, 2026
d218ed0
Register RequestCardReplacementCommand handler and add MSW stub
Mar 24, 2026
a87a667
Fix CardSelection confirm navigation to resolve as child route
Mar 24, 2026
d185074
Set Last4DigitsOfCard on all approved mock household scenarios
Mar 24, 2026
62eb5c4
Set explicit CardRequestedAt on all approved mock scenarios to avoid …
Mar 24, 2026
24a4a43
Add Playwright E2E tests for card replacement flow (42 tests)
Mar 25, 2026
083f07e
Fix E2E CardStatus enum values, remove double decodeURIComponent, add…
Mar 25, 2026
96c01c0
Merge main into DC-153 (design-system extraction + DC-130 dashboard)
Mar 26, 2026
e395546
Fix replacement card link routing and cooldown bypass on dashboard
Mar 27, 2026
7c56e8e
Use IssuerSigningKeyResolver for kid-less JWTs and suppress USWDS sas…
Mar 27, 2026
bc59ebf
Update locale CSVs and support new Google Sheet column headers
Mar 27, 2026
e9f5200
Add commented helper for CO OIDC local testing in mock data
Mar 27, 2026
3633f95
Merge main into DC-153
Mar 27, 2026
63b3cac
Extract shared IAL resolution, fix stub log message, fix office address
Mar 28, 2026
16ac3f8
Reject card replacement requests with application numbers not in hous…
Mar 29, 2026
1514a94
Merge branch 'main' into feat/DC-153-request-replacement-card-flow
Apr 1, 2026
0bf8d88
Address PR review: inject TimeProvider, add null-household test, remo…
Apr 1, 2026
3732888
Adopt UserIalLevelExtensions from DC-194 to avoid merge conflict
Apr 1, 2026
7e19f00
Merge branch 'main' into feat/DC-153-request-replacement-card-flow
Apr 1, 2026
bdc9c83
Merge branch 'main' into feat/DC-153-request-replacement-card-flow
necampanini Apr 1, 2026
840f560
Merge branch 'main' into feat/DC-153-request-replacement-card-flow
necampanini Apr 1, 2026
7a75664
Fix E2E workflow timeout and add test observability
Apr 2, 2026
942bedd
Merge branch 'main' into feat/DC-153-request-replacement-card-flow
Apr 2, 2026
08f16a7
Split Playwright and Pa11y into parallel CI jobs
Apr 2, 2026
3e2596a
Remove stub page that shadows address form route
Apr 2, 2026
6228bc6
Merge branch 'main' into feat/DC-153-request-replacement-card-flow
necampanini Apr 2, 2026
e7f68c8
Merge branch 'main' into feat/DC-153-request-replacement-card-flow
necampanini Apr 2, 2026
29ffe26
Use real addresses in mock data for Smarty validation compatibility
Apr 3, 2026
e624f35
Merge branch 'main' into feat/DC-153-request-replacement-card-flow
Apr 3, 2026
2d2d3ac
Merge branch 'main' into feat/DC-153-request-replacement-card-flow
Apr 6, 2026
03cef78
Fix CoLoadedInfo test fixture addresses to match updated MSW mock data
Apr 6, 2026
a80c060
Merge branch 'main' into feat/DC-153-request-replacement-card-flow
Apr 6, 2026
7e3aed1
split mixed-state E2E tests and add per-state CI matrix
Apr 6, 2026
d47db49
fix EnrolledChildren field mapping and update E2E fixtures for summer…
Apr 6, 2026
55c83d3
Merge branch 'main' into feat/DC-153-request-replacement-card-flow
Apr 7, 2026
587614e
Merge branch 'main' into feat/DC-153-request-replacement-card-flow
Apr 7, 2026
4a8c6ef
gate replacement link behind feature flag, improve CardSelection empt…
Apr 7, 2026
3a0cce7
Merge branch 'main' into feat/DC-153-request-replacement-card-flow
Apr 7, 2026
304b5fc
Merge branch 'main' into feat/DC-153-request-replacement-card-flow
Apr 7, 2026
5e0457c
Merge branch 'main' into feat/DC-153-request-replacement-card-flow
Apr 7, 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
84 changes: 65 additions & 19 deletions .github/workflows/playwright-e2e.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,70 @@ on:

jobs:
e2e:
name: E2E Tests
name: Playwright E2E Tests (${{ matrix.state }})
runs-on: ubuntu-latest
timeout-minutes: 15
strategy:
matrix:
state: [dc, co]
fail-fast: false

steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: "10"

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "24"
cache: "pnpm"

- name: Install dependencies
run: pnpm install --frozen-lockfile --prefer-offline

- name: Install Playwright browsers
run: cd src/SEBT.Portal.Web && pnpm exec playwright install --with-deps chromium

- name: Build frontend for E2E
env:
STATE: ${{ matrix.state }}
NEXT_PUBLIC_STATE: ${{ matrix.state }}
run: ./.github/workflows/scripts/build-frontend.sh --production

- name: Run Playwright E2E tests
env:
CI: true
SKIP_WEB_SERVER: "1"
STATE: ${{ matrix.state }}
NEXT_PUBLIC_STATE: ${{ matrix.state }}
run: |
(cd src/SEBT.Portal.Web && pnpm start) &
echo "Waiting for server at http://localhost:3000..."
for i in $(seq 1 90); do
curl -sf --max-time 15 http://localhost:3000 > /dev/null && echo "Server ready" && break
sleep 2
done
curl -sf --max-time 15 http://localhost:3000 > /dev/null || (echo "Server failed to start" && exit 1)
echo "Running Playwright E2E tests..."
pnpm ci:test:e2e

- name: Upload Playwright report
uses: actions/upload-artifact@v4
if: ${{ always() }}
with:
name: playwright-report-${{ matrix.state }}
path: src/SEBT.Portal.Web/playwright-report/
retention-days: 7

a11y:
name: Pa11y Accessibility Tests
runs-on: ubuntu-latest
timeout-minutes: 25

services:
mssql:
Expand Down Expand Up @@ -75,35 +136,30 @@ jobs:
- name: Build backend
run: ./.github/workflows/scripts/build-backend.sh --configuration Release

- name: Install Playwright browsers
run: cd src/SEBT.Portal.Web && pnpm exec playwright install --with-deps chromium

- name: Install Chrome for Pa11y
run: cd src/SEBT.Portal.Web && pnpm exec puppeteer browsers install chrome

- name: Build frontend for E2E
- name: Build frontend for Pa11y
env:
STATE: dc
NEXT_PUBLIC_STATE: dc
run: ./.github/workflows/scripts/build-frontend.sh --production

- name: Run Pa11y and Playwright E2E tests
- name: Run Pa11y accessibility tests
env:
CI: true
SKIP_WEB_SERVER: "1"
STATE: dc
NEXT_PUBLIC_STATE: dc
ASPNETCORE_ENVIRONMENT: Development
# Required at startup (AddPlugins); paths may be absent — MEF skips missing dirs and defaults apply.
ConnectionStrings__DefaultConnection: "Server=localhost,1433;Database=SebtPortal;User Id=sa;Password=YourStrong@Passw0rd;TrustServerCertificate=True;"
JwtSettings__SecretKey: "ci-e2e-jwt-secret-at-least-32-characters-long"
IdentifierHasher__SecretKey: "ci-e2e-identifier-hasher-key-32chars"
Oidc__CompleteLoginSigningKey: "ci-e2e-oidc-signing-key-at-least-32-chars"
PluginAssemblyPaths__0: "plugins-dc"
UseMockHouseholdData: "true"
run: |
# Start API in dev mode + frontend from production build
pnpm api:dev &
ASPNETCORE_ENVIRONMENT=Development dotnet run --project src/SEBT.Portal.Api --launch-profile http --configuration Release --no-build &
(cd src/SEBT.Portal.Web && pnpm start) &
echo "Waiting for server at http://localhost:3000..."
for i in $(seq 1 90); do
Expand All @@ -113,13 +169,3 @@ jobs:
curl -sf --max-time 15 http://localhost:3000 > /dev/null || (echo "Server failed to start" && exit 1)
echo "Running Pa11y accessibility tests..."
pnpm ci:test:a11y
echo "Running Playwright E2E tests..."
pnpm ci:test:e2e

- name: Upload Playwright report
uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with:
name: playwright-report
path: src/SEBT.Portal.Web/playwright-report/
retention-days: 7
9 changes: 5 additions & 4 deletions packages/design-system/content/scripts/generate-locales.js
Original file line number Diff line number Diff line change
Expand Up @@ -301,10 +301,11 @@ function parseContentKey(contentKey) {
function buildStateLocaleData(rows, state) {
const [headerRow, ...dataRows] = rows;

// Find column indices (handle emoji prefixes like "🟡 Content")
const contentIdx = headerRow.findIndex((h) =>
h.toLowerCase().includes('content')
);
// Find column indices (handle emoji prefixes like "🟡 Content" and renamed headers like "Variable Name/Key")
const contentIdx = headerRow.findIndex((h) => {
const lower = h.toLowerCase()
return lower.includes('content') || lower.includes('variable name')
});
const englishIdx = headerRow.findIndex((h) =>
h.toLowerCase().includes('english')
);
Expand Down
23 changes: 23 additions & 0 deletions packages/design-system/content/scripts/generate-locales.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -65,5 +65,28 @@ assert.ok(!enrollmentContent.includes('dashboard'), 'enrollment barrel must NOT
// Test: locale JSON was written to --out-dir (not to script's own directory)
assert.ok(existsSync(join(tmpDir, 'locales', 'en', 'co', 'landing.json')), 'locale JSON must be written to --out-dir')

// Test: new CSV column headers ("Variable Name/Key", "🟢 CO English", "🟢 CO Español")
setup()
const csvNewHeaders = [
'Variable Name/Key,🟢 CO English,⚪ SOURCE English,🟢 CO Español,⚪ SOURCE Español,Notes',
'GLOBAL - Button Continue,Continue,,Continuar,,',
'S1 - Landing Page - Title,New Header Landing,,New Header Landing ES,,',
].join('\n')
writeFileSync(join(tmpDir, 'states', 'co.csv'), csvNewHeaders)

execFileSync('node', [
script,
'--csv-dir', join(tmpDir, 'states'),
'--out-dir', join(tmpDir, 'locales'),
'--ts-out', join(tmpDir, 'ts-out', 'new-header-resources.ts'),
'--app', 'portal',
], { stdio: 'inherit' })

const newHeaderLanding = JSON.parse(readFileSync(join(tmpDir, 'locales', 'en', 'co', 'landing.json'), 'utf8'))
assert.equal(newHeaderLanding.title, 'New Header Landing', 'must parse content from "Variable Name/Key" column header')

const newHeaderCommon = JSON.parse(readFileSync(join(tmpDir, 'locales', 'es', 'co', 'common.json'), 'utf8'))
assert.equal(newHeaderCommon.buttonContinue, 'Continuar', 'must parse Spanish from state-prefixed Español column')

console.log('✅ All generate-locales CLI arg tests passed')
teardown()
5 changes: 4 additions & 1 deletion src/SEBT.Portal.Api/Controllers/Auth/OidcController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,10 @@ public async Task<IActionResult> CompleteLogin(
ValidateAudience = false,
ValidateLifetime = true,
ClockSkew = TimeSpan.FromMinutes(1),
IssuerSigningKey = key
// Use resolver instead of IssuerSigningKey to bypass kid-matching;
// jose (Next.js) signs without a kid header, which causes IDX10517
// when JwtSecurityTokenHandler tries to match by kid.
IssuerSigningKeyResolver = (token, securityToken, kid, parameters) => [key]
};
Comment thread
adbergen marked this conversation as resolved.
var handler = new JwtSecurityTokenHandler();
handler.MapInboundClaims = false; // Preserve original JWT claim names (sub, email)
Expand Down
32 changes: 32 additions & 0 deletions src/SEBT.Portal.Api/Controllers/Household/HouseholdController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -85,4 +85,36 @@ public async Task<IActionResult> UpdateAddress(
var result = await commandHandler.Handle(command, cancellationToken);
return result.ToActionResult();
}

/// <summary>
/// Requests replacement cards for the authenticated user's household.
/// </summary>
/// <param name="request">The application numbers to request replacements for.</param>
/// <param name="commandHandler">The use case handler for requesting card replacements.</param>
/// <param name="cancellationToken">A token to monitor for cancellation requests.</param>
/// <returns>No content on success; otherwise, BadRequest, Forbidden, or NotFound.</returns>
/// <response code="204">Card replacement request recorded successfully.</response>
/// <response code="400">Validation failed (no applications selected or cooldown active).</response>
/// <response code="403">User is not authorized or no household identifier could be resolved from token.</response>
/// <response code="404">Household data not found for the authenticated user.</response>
[HttpPost("cards/replace")]
[Authorize]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(typeof(ValidationProblemDetails), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status403Forbidden)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public async Task<IActionResult> RequestCardReplacement(
[FromBody] RequestCardReplacementRequest request,
[FromServices] ICommandHandler<RequestCardReplacementCommand> commandHandler,
CancellationToken cancellationToken = default)
{
var command = new RequestCardReplacementCommand
{
User = User,
ApplicationNumbers = request.ApplicationNumbers
};

var result = await commandHandler.Handle(command, cancellationToken);
return result.ToActionResult();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
using System.ComponentModel.DataAnnotations;

namespace SEBT.Portal.Api.Models.Household;

/// <summary>
/// Request model for requesting replacement cards for one or more applications.
/// </summary>
public record RequestCardReplacementRequest
{
/// <summary>Application numbers identifying which cards to replace.</summary>
[Required(ErrorMessage = "At least one application number is required.")]
[MinLength(1, ErrorMessage = "At least one application number is required.")]
public required List<string> ApplicationNumbers { get; init; }
}
6 changes: 3 additions & 3 deletions src/SEBT.Portal.TestUtilities/Helpers/HouseholdFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ private static Application CreateApplicationWithStatus(ApplicationStatus status,

if (status == ApplicationStatus.Approved)
{
application.ApplicationNumber = $"APP-{faker.Date.Recent(365):yyyy-MM}-{faker.Random.Number(100000, 999999)}";
application.ApplicationNumber = $"APP-{faker.Random.Number(2024, 2026)}-{faker.Random.Number(1, 12):D2}-{faker.Random.Number(100000, 999999)}";
application.CaseNumber = $"CASE-{faker.Random.Number(100000, 999999)}";
application.BenefitIssueDate = faker.Date.Recent(120);
application.BenefitExpirationDate = application.BenefitIssueDate.Value.AddDays(faker.Random.Int(30, 365));
Expand All @@ -135,7 +135,7 @@ private static Application CreateApplicationWithStatus(ApplicationStatus status,
}
else if (status == ApplicationStatus.Denied)
{
application.ApplicationNumber = $"APP-{faker.Date.Recent(365):yyyy-MM}-{faker.Random.Number(100000, 999999)}";
application.ApplicationNumber = $"APP-{faker.Random.Number(2024, 2026)}-{faker.Random.Number(1, 12):D2}-{faker.Random.Number(100000, 999999)}";
application.CaseNumber = $"CASE-{faker.Random.Number(100000, 999999)}";
if (faker.Random.Bool(0.5f))
{
Expand Down Expand Up @@ -164,7 +164,7 @@ private static Application CreateApplicationWithStatus(ApplicationStatus status,
else
{
// For other statuses (Pending, UnderReview, Cancelled)
application.ApplicationNumber = $"APP-{faker.Date.Recent(365):yyyy-MM}-{faker.Random.Number(100000, 999999)}";
application.ApplicationNumber = $"APP-{faker.Random.Number(2024, 2026)}-{faker.Random.Number(1, 12):D2}-{faker.Random.Number(100000, 999999)}";
if (faker.Random.Bool(0.5f))
{
application.CardStatus = CardStatus.Requested;
Expand Down
1 change: 1 addition & 0 deletions src/SEBT.Portal.UseCases/Dependencies.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ public static IServiceCollection AddUseCases(this IServiceCollection services)
services.RegisterCommandHandler<ProcessWebhookCommand, ProcessWebhookCommandHandler>();
services.RegisterCommandHandler<CheckEnrollmentCommand, EnrollmentCheckResult, CheckEnrollmentCommandHandler>();
services.RegisterCommandHandler<UpdateAddressCommand, UpdateAddressCommandHandler>();
services.RegisterCommandHandler<RequestCardReplacementCommand, RequestCardReplacementCommandHandler>();

return services;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
using System.ComponentModel.DataAnnotations;
using System.Security.Claims;
using SEBT.Portal.Kernel;

namespace SEBT.Portal.UseCases.Household;

/// <summary>
/// Command to request replacement cards for one or more applications.
/// </summary>
public class RequestCardReplacementCommand : ICommand
{
/// <summary>
/// The authenticated user's claims principal, used to resolve household identity.
/// </summary>
[Required]
public required ClaimsPrincipal User { get; init; }

/// <summary>
/// Application numbers identifying which cards to replace.
/// All children on a selected application share the same card.
/// </summary>
[Required(ErrorMessage = "At least one application number is required.")]
[MinLength(1, ErrorMessage = "At least one application number is required.")]
public required List<string> ApplicationNumbers { get; init; }
}
Loading
Loading