Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a150369fef |
@@ -70,12 +70,6 @@ jobs:
|
|||||||
restore-keys: |
|
restore-keys: |
|
||||||
nuget-${{ runner.os }}-
|
nuget-${{ runner.os }}-
|
||||||
- run: make unit
|
- run: make unit
|
||||||
# Job summary (#136): a per-service pass/fail table from the TRX `make unit` wrote.
|
|
||||||
- name: Unit test summary
|
|
||||||
if: always()
|
|
||||||
run: |
|
|
||||||
[ -n "${GITHUB_STEP_SUMMARY:-}" ] || exit 0
|
|
||||||
python3 infra/trx-summary.py TestResults >> "$GITHUB_STEP_SUMMARY"
|
|
||||||
|
|
||||||
# Frontend (Nx/Angular) lane: install with pnpm, then Nx lint + test + build.
|
# Frontend (Nx/Angular) lane: install with pnpm, then Nx lint + test + build.
|
||||||
frontend:
|
frontend:
|
||||||
@@ -90,12 +84,6 @@ jobs:
|
|||||||
node-version: '24'
|
node-version: '24'
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
- run: make frontend
|
- run: make frontend
|
||||||
# Job summary (#136): a per-frontend (app) pass/fail table from the vitest JSON each app wrote.
|
|
||||||
- name: Frontend test summary
|
|
||||||
if: always()
|
|
||||||
run: |
|
|
||||||
[ -n "${GITHUB_STEP_SUMMARY:-}" ] || exit 0
|
|
||||||
python3 infra/vitest-summary.py test-output >> "$GITHUB_STEP_SUMMARY"
|
|
||||||
|
|
||||||
mutation:
|
mutation:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -111,29 +99,6 @@ jobs:
|
|||||||
restore-keys: |
|
restore-keys: |
|
||||||
nuget-${{ runner.os }}-
|
nuget-${{ runner.os }}-
|
||||||
- run: make mutation
|
- run: make mutation
|
||||||
# Job summary (#136): render each service's Stryker Markdown report on the run page (Gitea
|
|
||||||
# 1.27 $GITHUB_STEP_SUMMARY). `if: always()` so a ratchet break still reports — and because
|
|
||||||
# `make mutation` stops at the first break, the summary also shows exactly where it stopped.
|
|
||||||
# Guarded so it no-ops on a runner/server without summary support. Strips the report's UTF-8 BOM.
|
|
||||||
- name: Mutation score summary
|
|
||||||
if: always()
|
|
||||||
run: |
|
|
||||||
[ -n "${GITHUB_STEP_SUMMARY:-}" ] || exit 0
|
|
||||||
{
|
|
||||||
echo "## 🧬 Mutation testing"
|
|
||||||
echo
|
|
||||||
for svc in acl event-subscriber domain bff; do
|
|
||||||
echo "### $svc"
|
|
||||||
echo
|
|
||||||
report=$(ls services/"$svc"/StrykerOutput/*/reports/mutation-report.md 2>/dev/null | sort | tail -1)
|
|
||||||
if [ -n "$report" ]; then
|
|
||||||
sed '1s/^\xef\xbb\xbf//' "$report"
|
|
||||||
else
|
|
||||||
echo "_No report — \`make mutation\` stopped before \`$svc\` (earlier ratchet break)._"
|
|
||||||
fi
|
|
||||||
echo
|
|
||||||
done
|
|
||||||
} >> "$GITHUB_STEP_SUMMARY"
|
|
||||||
# Publish the Stryker HTML reports. `if: always()` uploads them even when the
|
# Publish the Stryker HTML reports. `if: always()` uploads them even when the
|
||||||
# ratchet fails — that is exactly when you want to inspect the survivors.
|
# ratchet fails — that is exactly when you want to inspect the survivors.
|
||||||
# `continue-on-error` keeps the upload best-effort: the mutation *gate* is the
|
# `continue-on-error` keeps the upload best-effort: the mutation *gate* is the
|
||||||
@@ -193,80 +158,26 @@ jobs:
|
|||||||
- uses: https://github.com/actions/checkout@v4
|
- uses: https://github.com/actions/checkout@v4
|
||||||
# Bring the full stack up + wait for health — this also is the DoD "compose up
|
# Bring the full stack up + wait for health — this also is the DoD "compose up
|
||||||
# reaches green health" smoke (it replaces the old compose-smoke job).
|
# reaches green health" smoke (it replaces the old compose-smoke job).
|
||||||
# Each check carries an `id` so the summary step below can report its per-check outcome (#136).
|
|
||||||
# A failed check skips the rest (no step `if:`), so the table shows exactly where it stopped.
|
|
||||||
- name: Bring up the full stack & wait for health
|
- name: Bring up the full stack & wait for health
|
||||||
id: up
|
|
||||||
run: make verify-up
|
run: make verify-up
|
||||||
- name: Observability backplane (Grafana + Tempo + Prometheus datasources)
|
- name: Observability backplane (Grafana + Tempo + Prometheus datasources)
|
||||||
id: obs
|
|
||||||
run: OBS_TIMEOUT=180 make verify-observability
|
run: OBS_TIMEOUT=180 make verify-observability
|
||||||
- name: ACL ↔ OpenZaak integration tests
|
- name: ACL ↔ OpenZaak integration tests
|
||||||
id: acl
|
|
||||||
run: make verify-acl
|
run: make verify-acl
|
||||||
- name: OpenZaak → NRC notification delivery
|
- name: OpenZaak → NRC notification delivery
|
||||||
id: nrc
|
|
||||||
run: make verify-nrc
|
run: make verify-nrc
|
||||||
- name: OpenZaak → NRC → Event Subscriber → projection-api
|
- name: OpenZaak → NRC → Event Subscriber → projection-api
|
||||||
id: projection
|
|
||||||
run: make verify-projection
|
run: make verify-projection
|
||||||
- name: Domain → Flowable → ACL → OpenZaak
|
- name: Domain → Flowable → ACL → OpenZaak
|
||||||
id: domain
|
|
||||||
run: make verify-domain
|
run: make verify-domain
|
||||||
- name: BFF → Keycloak + domain + projection
|
- name: BFF → Keycloak + domain + projection
|
||||||
id: bff
|
|
||||||
run: make verify-bff
|
run: make verify-bff
|
||||||
- name: Distributed traces reach Tempo (one connected trace across services)
|
- name: Distributed traces reach Tempo (one connected trace across services)
|
||||||
id: tracing
|
|
||||||
run: TRACING_TIMEOUT=120 make verify-tracing
|
run: TRACING_TIMEOUT=120 make verify-tracing
|
||||||
- name: Golden-signal metrics scraped by Prometheus (/metrics on every service)
|
- name: Golden-signal metrics scraped by Prometheus (/metrics on every service)
|
||||||
id: metrics
|
|
||||||
run: METRICS_TIMEOUT=120 make verify-metrics
|
run: METRICS_TIMEOUT=120 make verify-metrics
|
||||||
- name: Self-service e2e (Playwright, login → submit → success)
|
- name: Self-service e2e (Playwright, login → submit → success)
|
||||||
id: e2e
|
|
||||||
run: make verify-e2e
|
run: make verify-e2e
|
||||||
# Job summary (#136): a pass/fail table of every live-stack check, so a red verify-stack shows
|
|
||||||
# which check failed at a glance. `if: always()` (step-level — safe on runner 2.0.0, unlike the
|
|
||||||
# job-level status-function `if` of #134) so it renders even after a check fails.
|
|
||||||
- name: verify-stack check summary
|
|
||||||
if: always()
|
|
||||||
env:
|
|
||||||
UP: ${{ steps.up.outcome }}
|
|
||||||
OBS: ${{ steps.obs.outcome }}
|
|
||||||
ACL: ${{ steps.acl.outcome }}
|
|
||||||
NRC: ${{ steps.nrc.outcome }}
|
|
||||||
PROJECTION: ${{ steps.projection.outcome }}
|
|
||||||
DOMAIN: ${{ steps.domain.outcome }}
|
|
||||||
BFF: ${{ steps.bff.outcome }}
|
|
||||||
TRACING: ${{ steps.tracing.outcome }}
|
|
||||||
METRICS: ${{ steps.metrics.outcome }}
|
|
||||||
E2E: ${{ steps.e2e.outcome }}
|
|
||||||
run: |
|
|
||||||
[ -n "${GITHUB_STEP_SUMMARY:-}" ] || exit 0
|
|
||||||
icon() { case "$1" in success) echo "✅";; failure) echo "❌";; skipped) echo "⏭️";; cancelled) echo "🚫";; *) echo "❔ ${1:-—}";; esac; }
|
|
||||||
{
|
|
||||||
echo "## 🔌 verify-stack checks"
|
|
||||||
echo
|
|
||||||
echo "| Check | Result |"
|
|
||||||
echo "| ----- | :----: |"
|
|
||||||
echo "| Bring up + health | $(icon "$UP") |"
|
|
||||||
echo "| Observability backplane | $(icon "$OBS") |"
|
|
||||||
echo "| ACL ↔ OpenZaak | $(icon "$ACL") |"
|
|
||||||
echo "| OpenZaak → NRC | $(icon "$NRC") |"
|
|
||||||
echo "| NRC → Event Subscriber → projection | $(icon "$PROJECTION") |"
|
|
||||||
echo "| Domain → Flowable → ACL → OpenZaak | $(icon "$DOMAIN") |"
|
|
||||||
echo "| BFF → Keycloak + domain + projection | $(icon "$BFF") |"
|
|
||||||
echo "| Distributed traces (Tempo) | $(icon "$TRACING") |"
|
|
||||||
echo "| Golden-signal metrics (Prometheus) | $(icon "$METRICS") |"
|
|
||||||
echo "| Self-service e2e (Playwright) | $(icon "$E2E") |"
|
|
||||||
} >> "$GITHUB_STEP_SUMMARY"
|
|
||||||
# Job summary (#136): per-spec Playwright results, from the JSON report run-e2e-check.sh copied
|
|
||||||
# out of the e2e container. Turns a red e2e into a one-glance "which spec" instead of a log dive.
|
|
||||||
- name: e2e spec summary
|
|
||||||
if: always()
|
|
||||||
run: |
|
|
||||||
[ -n "${GITHUB_STEP_SUMMARY:-}" ] || exit 0
|
|
||||||
python3 infra/playwright-summary.py tests/e2e/playwright-report.json >> "$GITHUB_STEP_SUMMARY"
|
|
||||||
# Log dump must precede teardown (which removes the containers).
|
# Log dump must precede teardown (which removes the containers).
|
||||||
- name: Dump container logs on failure
|
- name: Dump container logs on failure
|
||||||
if: failure()
|
if: failure()
|
||||||
|
|||||||
@@ -58,6 +58,3 @@ tests/e2e/node_modules/
|
|||||||
tests/e2e/test-results/
|
tests/e2e/test-results/
|
||||||
tests/e2e/playwright-report/
|
tests/e2e/playwright-report/
|
||||||
__pycache__/
|
__pycache__/
|
||||||
TestResults/
|
|
||||||
test-output/
|
|
||||||
tests/e2e/playwright-report.json
|
|
||||||
|
|||||||
@@ -70,9 +70,8 @@ build:
|
|||||||
dotnet build $(SLN) -c Release
|
dotnet build $(SLN) -c Release
|
||||||
|
|
||||||
## unit: run unit tests (excludes the container-backed Integration lane)
|
## unit: run unit tests (excludes the container-backed Integration lane)
|
||||||
# TRX per test project (→ TestResults/) feeds the CI per-service summary (#136); harmless locally.
|
|
||||||
unit:
|
unit:
|
||||||
dotnet test $(SLN) -c Release --filter "Category!=Integration" --logger trx --results-directory TestResults
|
dotnet test $(SLN) -c Release --filter "Category!=Integration"
|
||||||
|
|
||||||
## mutation: run the Stryker.NET ratchet on each service with branching logic (fails below baseline)
|
## mutation: run the Stryker.NET ratchet on each service with branching logic (fails below baseline)
|
||||||
# Stryker is pinned as a local dotnet tool (.config/dotnet-tools.json); `tool restore`
|
# Stryker is pinned as a local dotnet tool (.config/dotnet-tools.json); `tool restore`
|
||||||
|
|||||||
@@ -64,9 +64,7 @@
|
|||||||
"test": {
|
"test": {
|
||||||
"executor": "@angular/build:unit-test",
|
"executor": "@angular/build:unit-test",
|
||||||
"options": {
|
"options": {
|
||||||
"watch": false,
|
"watch": false
|
||||||
"reporters": ["default", "json"],
|
|
||||||
"outputFile": "{workspaceRoot}/test-output/{projectName}.json"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"serve-static": {
|
"serve-static": {
|
||||||
|
|||||||
@@ -64,9 +64,7 @@
|
|||||||
"test": {
|
"test": {
|
||||||
"executor": "@angular/build:unit-test",
|
"executor": "@angular/build:unit-test",
|
||||||
"options": {
|
"options": {
|
||||||
"watch": false,
|
"watch": false
|
||||||
"reporters": ["default", "json"],
|
|
||||||
"outputFile": "{workspaceRoot}/test-output/{projectName}.json"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"serve-static": {
|
"serve-static": {
|
||||||
|
|||||||
@@ -1,5 +1 @@
|
|||||||
<nav aria-label="Beheer" class="utrecht-theme">
|
|
||||||
<a routerLink="/" routerLinkActive="active" [routerLinkActiveOptions]="{ exact: true }">Catalogus</a>
|
|
||||||
<a routerLink="/default-fill" routerLinkActive="active">Default-fill</a>
|
|
||||||
</nav>
|
|
||||||
<router-outlet></router-outlet>
|
<router-outlet></router-outlet>
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
import { Route } from '@angular/router';
|
import { Route } from '@angular/router';
|
||||||
import { authenticatedGuard } from 'auth';
|
import { authenticatedGuard } from 'auth';
|
||||||
import { CatalogusPage } from './catalogus/catalogus-page';
|
import { CatalogusPage } from './catalogus/catalogus-page';
|
||||||
import { DefaultFillPage } from './default-fill/default-fill-page';
|
|
||||||
|
|
||||||
export const appRoutes: Route[] = [
|
export const appRoutes: Route[] = [
|
||||||
{ path: '', component: CatalogusPage, canActivate: [authenticatedGuard] },
|
{ path: '', component: CatalogusPage, canActivate: [authenticatedGuard] },
|
||||||
{ path: 'default-fill', component: DefaultFillPage, canActivate: [authenticatedGuard] },
|
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,60 +0,0 @@
|
|||||||
<main utrecht-document class="utrecht-theme">
|
|
||||||
<utrecht-article>
|
|
||||||
<utrecht-heading-1>Default-fill</utrecht-heading-1>
|
|
||||||
<p utrecht-paragraph>
|
|
||||||
De ZGW-standaardwaarden die de ACL op elke nieuwe zaak invult (ADR-0003). Een wijziging geldt
|
|
||||||
voor de eerstvolgende zaak.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
@if (loading()) {
|
|
||||||
<p utrecht-paragraph role="status">Bezig met laden…</p>
|
|
||||||
} @else if (loaded()) {
|
|
||||||
<form (submit)="save(); $event.preventDefault()">
|
|
||||||
<p>
|
|
||||||
<label for="bronorganisatie">Bronorganisatie</label><br />
|
|
||||||
<input
|
|
||||||
id="bronorganisatie"
|
|
||||||
name="bronorganisatie"
|
|
||||||
[value]="bronorganisatie()"
|
|
||||||
(input)="bronorganisatie.set($any($event.target).value)"
|
|
||||||
/>
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<label for="verantwoordelijkeOrganisatie">Verantwoordelijke organisatie</label><br />
|
|
||||||
<input
|
|
||||||
id="verantwoordelijkeOrganisatie"
|
|
||||||
name="verantwoordelijkeOrganisatie"
|
|
||||||
[value]="verantwoordelijkeOrganisatie()"
|
|
||||||
(input)="verantwoordelijkeOrganisatie.set($any($event.target).value)"
|
|
||||||
/>
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<label for="vertrouwelijkheidaanduiding">Vertrouwelijkheidaanduiding</label><br />
|
|
||||||
<input
|
|
||||||
id="vertrouwelijkheidaanduiding"
|
|
||||||
name="vertrouwelijkheidaanduiding"
|
|
||||||
[value]="vertrouwelijkheidaanduiding()"
|
|
||||||
(input)="vertrouwelijkheidaanduiding.set($any($event.target).value)"
|
|
||||||
/>
|
|
||||||
</p>
|
|
||||||
<button utrecht-button appearance="primary-action-button" type="submit" [disabled]="saving()">
|
|
||||||
Opslaan
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
@if (saved()) {
|
|
||||||
<p utrecht-paragraph role="status">De standaardwaarden zijn opgeslagen.</p>
|
|
||||||
}
|
|
||||||
@if (failed()) {
|
|
||||||
<p utrecht-paragraph role="alert">
|
|
||||||
Opslaan is niet gelukt. Controleer of je als beheerder bent ingelogd en probeer het opnieuw.
|
|
||||||
</p>
|
|
||||||
}
|
|
||||||
} @else if (failed()) {
|
|
||||||
<p utrecht-paragraph role="alert">
|
|
||||||
Kon de standaardwaarden niet laden. Controleer of je als beheerder bent ingelogd en probeer
|
|
||||||
het opnieuw.
|
|
||||||
</p>
|
|
||||||
}
|
|
||||||
</utrecht-article>
|
|
||||||
</main>
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
import { signal } from '@angular/core';
|
|
||||||
import { fireEvent, render, screen } from '@testing-library/angular';
|
|
||||||
import { of, throwError } from 'rxjs';
|
|
||||||
import { BeheerDefaultFill, BffApiV1Service } from 'api-client';
|
|
||||||
import { AuthService } from 'auth';
|
|
||||||
import { axe } from 'vitest-axe';
|
|
||||||
import { DefaultFillPage } from './default-fill-page';
|
|
||||||
|
|
||||||
const current: BeheerDefaultFill = {
|
|
||||||
bronorganisatie: '517439943',
|
|
||||||
verantwoordelijkeOrganisatie: '517439943',
|
|
||||||
vertrouwelijkheidaanduiding: 'openbaar',
|
|
||||||
};
|
|
||||||
|
|
||||||
class FakeAuth extends AuthService {
|
|
||||||
readonly isAuthenticated = signal(true);
|
|
||||||
readonly bsn = signal<string | undefined>(undefined);
|
|
||||||
override readonly roles = signal<readonly string[]>(['beheerder']);
|
|
||||||
login(): void {
|
|
||||||
/* not exercised */
|
|
||||||
}
|
|
||||||
logout(): void {
|
|
||||||
/* not exercised */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function setup(
|
|
||||||
overrides: {
|
|
||||||
getBeheerDefaultFill?: ReturnType<typeof vi.fn>;
|
|
||||||
putBeheerDefaultFill?: ReturnType<typeof vi.fn>;
|
|
||||||
} = {},
|
|
||||||
) {
|
|
||||||
const getBeheerDefaultFill = overrides.getBeheerDefaultFill ?? vi.fn().mockReturnValue(of(current));
|
|
||||||
const putBeheerDefaultFill = overrides.putBeheerDefaultFill ?? vi.fn().mockReturnValue(of(undefined));
|
|
||||||
return {
|
|
||||||
getBeheerDefaultFill,
|
|
||||||
putBeheerDefaultFill,
|
|
||||||
providers: [
|
|
||||||
{ provide: BffApiV1Service, useValue: { getBeheerDefaultFill, putBeheerDefaultFill } },
|
|
||||||
{ provide: AuthService, useClass: FakeAuth },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('DefaultFillPage', () => {
|
|
||||||
it('loads the current default-fill into the form on open', async () => {
|
|
||||||
const { getBeheerDefaultFill, providers } = setup();
|
|
||||||
await render(DefaultFillPage, { providers });
|
|
||||||
|
|
||||||
expect(getBeheerDefaultFill).toHaveBeenCalled();
|
|
||||||
const bron = (await screen.findByLabelText('Bronorganisatie')) as HTMLInputElement;
|
|
||||||
expect(bron.value).toBe('517439943');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('saves the edited values via the BFF', async () => {
|
|
||||||
const { putBeheerDefaultFill, providers } = setup();
|
|
||||||
await render(DefaultFillPage, { providers });
|
|
||||||
|
|
||||||
const bron = (await screen.findByLabelText('Bronorganisatie')) as HTMLInputElement;
|
|
||||||
fireEvent.input(bron, { target: { value: '999999999' } });
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: /opslaan/i }));
|
|
||||||
|
|
||||||
expect(putBeheerDefaultFill).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({ bronorganisatie: '999999999', vertrouwelijkheidaanduiding: 'openbaar' }),
|
|
||||||
);
|
|
||||||
expect(await screen.findByText(/standaardwaarden zijn opgeslagen/i)).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('surfaces a save failure instead of swallowing it', async () => {
|
|
||||||
const { providers } = setup({
|
|
||||||
putBeheerDefaultFill: vi.fn().mockReturnValue(throwError(() => new Error('403'))),
|
|
||||||
});
|
|
||||||
await render(DefaultFillPage, { providers });
|
|
||||||
|
|
||||||
fireEvent.click(await screen.findByRole('button', { name: /opslaan/i }));
|
|
||||||
|
|
||||||
expect(await screen.findByText(/opslaan is niet gelukt/i)).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('has no WCAG 2.1 AA violations', async () => {
|
|
||||||
document.documentElement.lang = 'nl';
|
|
||||||
const { container } = await render(DefaultFillPage, { providers: setup().providers });
|
|
||||||
|
|
||||||
const results = await axe(container, {
|
|
||||||
runOnly: { type: 'tag', values: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'] },
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(results.violations).toEqual([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
import { Component, inject, signal } from '@angular/core';
|
|
||||||
import { BeheerDefaultFill, BffApiV1Service } from 'api-client';
|
|
||||||
import { UtrechtComponentsModule } from 'ui';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The beheer default-fill editor (S-15b): a beheerder reads and edits the ZGW default-fill values the
|
|
||||||
* ACL stamps on every zaak (ADR-0003). Load and save go through the BFF (`/beheer/default-fill`),
|
|
||||||
* which proxies the ACL (ADR-0025). A save takes effect on the next zaak (the ACL reads it per zaak).
|
|
||||||
*/
|
|
||||||
@Component({
|
|
||||||
selector: 'app-default-fill-page',
|
|
||||||
imports: [UtrechtComponentsModule],
|
|
||||||
templateUrl: './default-fill-page.html',
|
|
||||||
})
|
|
||||||
export class DefaultFillPage {
|
|
||||||
private readonly bff = inject(BffApiV1Service);
|
|
||||||
|
|
||||||
protected readonly bronorganisatie = signal('');
|
|
||||||
protected readonly verantwoordelijkeOrganisatie = signal('');
|
|
||||||
protected readonly vertrouwelijkheidaanduiding = signal('');
|
|
||||||
protected readonly loading = signal(false);
|
|
||||||
protected readonly loaded = signal(false);
|
|
||||||
protected readonly saving = signal(false);
|
|
||||||
protected readonly failed = signal(false);
|
|
||||||
protected readonly saved = signal(false);
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
this.load();
|
|
||||||
}
|
|
||||||
|
|
||||||
load(): void {
|
|
||||||
this.loading.set(true);
|
|
||||||
this.failed.set(false);
|
|
||||||
this.saved.set(false);
|
|
||||||
this.bff.getBeheerDefaultFill().subscribe({
|
|
||||||
next: (d: BeheerDefaultFill) => {
|
|
||||||
this.bronorganisatie.set(d.bronorganisatie);
|
|
||||||
this.verantwoordelijkeOrganisatie.set(d.verantwoordelijkeOrganisatie);
|
|
||||||
this.vertrouwelijkheidaanduiding.set(d.vertrouwelijkheidaanduiding);
|
|
||||||
this.loading.set(false);
|
|
||||||
this.loaded.set(true);
|
|
||||||
},
|
|
||||||
error: () => {
|
|
||||||
this.loading.set(false);
|
|
||||||
this.loaded.set(true);
|
|
||||||
this.failed.set(true);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
save(): void {
|
|
||||||
this.saving.set(true);
|
|
||||||
this.failed.set(false);
|
|
||||||
this.saved.set(false);
|
|
||||||
this.bff
|
|
||||||
.putBeheerDefaultFill({
|
|
||||||
bronorganisatie: this.bronorganisatie(),
|
|
||||||
verantwoordelijkeOrganisatie: this.verantwoordelijkeOrganisatie(),
|
|
||||||
vertrouwelijkheidaanduiding: this.vertrouwelijkheidaanduiding(),
|
|
||||||
})
|
|
||||||
.subscribe({
|
|
||||||
next: () => {
|
|
||||||
this.saving.set(false);
|
|
||||||
this.saved.set(true);
|
|
||||||
},
|
|
||||||
error: () => {
|
|
||||||
this.saving.set(false);
|
|
||||||
this.failed.set(true);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -64,9 +64,7 @@
|
|||||||
"test": {
|
"test": {
|
||||||
"executor": "@angular/build:unit-test",
|
"executor": "@angular/build:unit-test",
|
||||||
"options": {
|
"options": {
|
||||||
"watch": false,
|
"watch": false
|
||||||
"reporters": ["default", "json"],
|
|
||||||
"outputFile": "{workspaceRoot}/test-output/{projectName}.json"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"serve-static": {
|
"serve-static": {
|
||||||
|
|||||||
@@ -64,9 +64,7 @@
|
|||||||
"test": {
|
"test": {
|
||||||
"executor": "@angular/build:unit-test",
|
"executor": "@angular/build:unit-test",
|
||||||
"options": {
|
"options": {
|
||||||
"watch": false,
|
"watch": false
|
||||||
"reporters": ["default", "json"],
|
|
||||||
"outputFile": "{workspaceRoot}/test-output/{projectName}.json"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"serve-static": {
|
"serve-static": {
|
||||||
|
|||||||
@@ -1,61 +0,0 @@
|
|||||||
# ADR-0026: Runtime-mutable ACL default-fill (in-memory store, seeded from config)
|
|
||||||
|
|
||||||
- **Status:** Accepted
|
|
||||||
- **Date:** 2026-07-24
|
|
||||||
- **Deciders:** Respellion engineering
|
|
||||||
- **Slice:** S-15b (#131), second of the S-15 (#16) split
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
ADR-0003 made the ACL *default-fill* the ZGW-mandatory fields it stamps on every
|
|
||||||
zaak, supplied as static configuration (`Acl:Defaults`, read once at startup as an
|
|
||||||
immutable singleton). S-15b lets a beheerder **edit** those values from the portal
|
|
||||||
and have the next zaak reflect them — so the defaults must become mutable at runtime.
|
|
||||||
|
|
||||||
Two questions: **what** is editable, and **where** the mutable state lives.
|
|
||||||
|
|
||||||
## Decision
|
|
||||||
|
|
||||||
**Make the three ZGW default-fill fields a runtime-mutable, in-memory store
|
|
||||||
(`IDefaultFillStore`), seeded from `Acl:Defaults` at startup. The ACL reads it per
|
|
||||||
zaak; the beheer `PUT /default-fill` replaces it.**
|
|
||||||
|
|
||||||
### Only the three ZGW fill fields are editable
|
|
||||||
|
|
||||||
`Acl:Defaults` also carries the S-27 catalog-resolution keys (`ZaaktypeIdentificatie`,
|
|
||||||
`InformatieobjecttypeOmschrijving`). Those feed the resolved-URL cache
|
|
||||||
(`CachedZaaktypeCatalog`, ADR-0021); editing them at runtime would leave a stale cache
|
|
||||||
and is catalogus *wiring*, not "default fill". So they **stay static config** and are
|
|
||||||
out of scope for the CRUD. The editable set is exactly `Bronorganisatie`,
|
|
||||||
`VerantwoordelijkeOrganisatie`, `Vertrouwelijkheidaanduiding` (`DefaultFillSettings`).
|
|
||||||
|
|
||||||
### In-memory, not persisted
|
|
||||||
|
|
||||||
The store is a thread-safe in-memory singleton. **An edit is lost on restart**, when it
|
|
||||||
reverts to the configured env. That is acceptable for this reference app: the slice
|
|
||||||
demonstrates the *pattern* (beheer edits config that the ACL honours), not durable
|
|
||||||
config management. The ACL stays stateless — no DB, no EF, no migration, no extra
|
|
||||||
compose service.
|
|
||||||
|
|
||||||
- ponytail ceiling: no persistence, no audit trail, no optimistic concurrency.
|
|
||||||
- Upgrade path: back `IDefaultFillStore` with a DB (or an Objecten record) if durable,
|
|
||||||
audited, multi-instance config is needed — the port stays the same.
|
|
||||||
|
|
||||||
## Consequences
|
|
||||||
|
|
||||||
**Positive**
|
|
||||||
|
|
||||||
- Demoable end to end (edit in portal → next zaak reflects it) with minimal moving parts.
|
|
||||||
- The read path is per-zaak, so no restart and no cache concerns for the ZGW fields.
|
|
||||||
|
|
||||||
**Negative / costs**
|
|
||||||
|
|
||||||
- Edits don't survive a restart and aren't shared across replicas (single-instance
|
|
||||||
assumption). Documented ceiling above.
|
|
||||||
- Two sources of default config now (static keys on `AclDefaults`, mutable fields in the
|
|
||||||
store) — a deliberate split by editability.
|
|
||||||
|
|
||||||
## Coupling rules touched (CLAUDE.md §8)
|
|
||||||
|
|
||||||
None new. The BFF→ACL edge already exists (ADR-0025); this adds a read/write pair on it.
|
|
||||||
The ACL remains the owner of the ZGW-facing config.
|
|
||||||
@@ -5,28 +5,6 @@ copy-pasteable walkthrough against a local `make up` stack.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## S-15b — Beheer-portal: default-fill configuration editor (#131, ADR-0026)
|
|
||||||
|
|
||||||
**Outcome:** a beheerder edits the ACL's ZGW **default-fill** values (bronorganisatie,
|
|
||||||
verantwoordelijke organisatie, vertrouwelijkheidaanduiding) from the beheer portal, and the next zaak
|
|
||||||
is stamped with the new values — no restart. Path: portal → BFF `GET/PUT /beheer/default-fill`
|
|
||||||
(beheerder role) → ACL `GET/PUT /default-fill` → a runtime-mutable in-memory store the ACL reads per
|
|
||||||
zaak (ADR-0026). The S-27 catalog-resolution keys stay static config (editing them would desync the
|
|
||||||
zaaktype cache). Store is in-memory: an edit reverts to the configured env on restart.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
make up
|
|
||||||
# 1. Log in as bram-beheerder / test123 → "Default-fill" tab → change a value → Opslaan.
|
|
||||||
open http://localhost:8143/default-fill
|
|
||||||
#
|
|
||||||
# 2. Automated: the ACL uses the current default-fill per zaak (unit) and the endpoints are behind the
|
|
||||||
# beheerder role (BFF unit):
|
|
||||||
# Acl.Tests → AclServiceTests.Opening_a_zaak_reflects_a_default_fill_update
|
|
||||||
# Bff.Tests → BeheerDefaultFillEndpointTests
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## S-15a — Beheer-portal: read-only catalogus viewer (#130, ADR-0025)
|
## S-15a — Beheer-portal: read-only catalogus viewer (#130, ADR-0025)
|
||||||
|
|
||||||
**Outcome:** a new **beheer** portal (medewerker realm, like behandel) shows the ZTC catalogus —
|
**Outcome:** a new **beheer** portal (medewerker realm, like behandel) shows the ZTC catalogus —
|
||||||
|
|||||||
@@ -221,27 +221,3 @@ fails", prefer serialising with a `concurrency` group over `needs` + `always()`.
|
|||||||
**Also** — a run already stuck this way will **not** clear itself; force-cancel it
|
**Also** — a run already stuck this way will **not** clear itself; force-cancel it
|
||||||
from the Actions UI (plain cancel can also stall on this version, #35782). Push the
|
from the Actions UI (plain cancel can also stall on this version, #35782). Push the
|
||||||
workflow fix to produce a fresh run.
|
workflow fix to produce a fresh run.
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Job summaries (`$GITHUB_STEP_SUMMARY`) need Gitea ≥1.27 + runner ≥2.0
|
|
||||||
|
|
||||||
Markdown a step appends to the `$GITHUB_STEP_SUMMARY` file renders on the run page
|
|
||||||
(no artifact download). We use it for per-run reports (#136): mutation scores
|
|
||||||
(Stryker `markdown` reporter), per-service unit results (`infra/trx-summary.py` over
|
|
||||||
TRX), per-frontend results (`infra/vitest-summary.py` over each app's vitest JSON),
|
|
||||||
the verify-stack check table, and per-spec e2e results (`infra/playwright-summary.py`).
|
|
||||||
|
|
||||||
**Requirements / conventions:**
|
|
||||||
|
|
||||||
- Requires **Gitea ≥ 1.27** (stores/renders summaries) and **act_runner ≥ 2.0.0**
|
|
||||||
(uploads them). Older pairings silently skip the upload.
|
|
||||||
- **Guard every write:** `[ -n "${GITHUB_STEP_SUMMARY:-}" ] || exit 0` — on a runner
|
|
||||||
without support the var is unset and `>> "$GITHUB_STEP_SUMMARY"` would be an
|
|
||||||
ambiguous-redirect error. The guard makes the step a no-op locally / on old runners.
|
|
||||||
- Use `if: always()` (step-level) on summary steps so they render even when the thing
|
|
||||||
they report on failed. Step-level `always()` is fine on 2.0.0 — unlike the *job*-level
|
|
||||||
status-function `if` of §7.
|
|
||||||
- Getting a report out of the e2e container: Playwright writes `playwright-report.json`
|
|
||||||
inside the container; `infra/run-e2e-check.sh` `docker cp`s it back to the host
|
|
||||||
(capturing the test exit code first) so the summary step can read it.
|
|
||||||
|
|||||||
@@ -1,57 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Render a per-spec table from a Playwright JSON report for a Gitea job summary (#136).
|
|
||||||
|
|
||||||
Reads the JSON report (default: tests/e2e/playwright-report.json) that run-e2e-check.sh copies out
|
|
||||||
of the e2e container, and prints a markdown table (one row per spec) to stdout. The CI step
|
|
||||||
redirects it into $GITHUB_STEP_SUMMARY. Stdlib only.
|
|
||||||
"""
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
|
|
||||||
STATUS_ICON = {"expected": "✅", "unexpected": "❌", "skipped": "⏭️", "flaky": "⚠️"}
|
|
||||||
|
|
||||||
|
|
||||||
def walk(suite, out):
|
|
||||||
for spec in suite.get("specs", []):
|
|
||||||
# A spec's status is carried on its test(s): expected/unexpected/skipped/flaky.
|
|
||||||
statuses = [t.get("status") for t in spec.get("tests", [])]
|
|
||||||
status = ("unexpected" if "unexpected" in statuses
|
|
||||||
else "flaky" if "flaky" in statuses
|
|
||||||
else "skipped" if statuses and all(s == "skipped" for s in statuses)
|
|
||||||
else "expected" if spec.get("ok", False)
|
|
||||||
else "unexpected")
|
|
||||||
out.append({"file": spec.get("file") or suite.get("file") or suite.get("title", ""),
|
|
||||||
"title": spec.get("title", ""), "status": status})
|
|
||||||
for child in suite.get("suites", []):
|
|
||||||
walk(child, out)
|
|
||||||
|
|
||||||
|
|
||||||
def main(path):
|
|
||||||
if not os.path.exists(path):
|
|
||||||
print("## 🎭 e2e (Playwright)\n\n_No e2e report — the run did not reach the e2e step._")
|
|
||||||
return 0
|
|
||||||
with open(path) as fh:
|
|
||||||
report = json.load(fh)
|
|
||||||
specs = []
|
|
||||||
for suite in report.get("suites", []):
|
|
||||||
walk(suite, specs)
|
|
||||||
|
|
||||||
print("## 🎭 e2e (Playwright)\n")
|
|
||||||
stats = report.get("stats", {})
|
|
||||||
if stats:
|
|
||||||
print(f"**{stats.get('expected', 0)} passed · {stats.get('unexpected', 0)} failed · "
|
|
||||||
f"{stats.get('flaky', 0)} flaky · {stats.get('skipped', 0)} skipped** "
|
|
||||||
f"({round(stats.get('duration', 0) / 1000)}s)\n")
|
|
||||||
if not specs:
|
|
||||||
print("_No specs ran._")
|
|
||||||
return 0
|
|
||||||
print("| Spec | Result |")
|
|
||||||
print("| ---- | :----: |")
|
|
||||||
for s in specs:
|
|
||||||
print(f"| {s['file']} › {s['title']} | {STATUS_ICON.get(s['status'], '❔')} |")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
sys.exit(main(sys.argv[1] if len(sys.argv) > 1 else "tests/e2e/playwright-report.json"))
|
|
||||||
@@ -26,8 +26,4 @@ cid="$(docker create --network "$net" -w /e2e --ipc=host \
|
|||||||
mcr.microsoft.com/playwright:v1.61.1-noble sh -c 'npm install --no-audit --no-fund && npx playwright test')"
|
mcr.microsoft.com/playwright:v1.61.1-noble sh -c 'npm install --no-audit --no-fund && npx playwright test')"
|
||||||
trap 'docker rm -f "$cid" >/dev/null 2>&1 || true' EXIT
|
trap 'docker rm -f "$cid" >/dev/null 2>&1 || true' EXIT
|
||||||
docker cp "$root/tests/e2e/." "$cid:/e2e" >/dev/null
|
docker cp "$root/tests/e2e/." "$cid:/e2e" >/dev/null
|
||||||
rc=0
|
docker start -a "$cid"
|
||||||
docker start -a "$cid" || rc=$?
|
|
||||||
# Copy the Playwright JSON report out — regardless of pass/fail — for the CI job summary (#136).
|
|
||||||
docker cp "$cid:/e2e/playwright-report.json" "$root/tests/e2e/playwright-report.json" 2>/dev/null || true
|
|
||||||
exit $rc
|
|
||||||
|
|||||||
@@ -1,65 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Render a per-test-project table from .trx files for a Gitea job summary (#136).
|
|
||||||
|
|
||||||
Reads every *.trx in the given directory (default: TestResults), pulls each project's
|
|
||||||
counters + assembly name, and prints a GitHub/Gitea-flavoured markdown table to stdout.
|
|
||||||
The CI step redirects that into $GITHUB_STEP_SUMMARY. Stdlib only.
|
|
||||||
"""
|
|
||||||
import glob
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
import xml.etree.ElementTree as ET
|
|
||||||
|
|
||||||
NS = {"t": "http://microsoft.com/schemas/VisualStudio/TeamTest/2010"}
|
|
||||||
|
|
||||||
|
|
||||||
def project_name(root):
|
|
||||||
# The test assembly path, e.g. …/services/domain/Big.Tests/bin/…/big.tests.dll. Prefer the
|
|
||||||
# owning service folder (services/<name>) so "domain" shows rather than the opaque "big.tests";
|
|
||||||
# fall back to the assembly basename for projects outside services/ (e.g. tests/acceptance).
|
|
||||||
ut = root.find(".//t:TestDefinitions/t:UnitTest", NS)
|
|
||||||
storage = ut.get("storage") if ut is not None else None
|
|
||||||
if not storage:
|
|
||||||
return None
|
|
||||||
parts = storage.replace("\\", "/").split("/")
|
|
||||||
if "services" in parts:
|
|
||||||
return parts[parts.index("services") + 1]
|
|
||||||
base = os.path.basename(parts[-1])
|
|
||||||
return base[:-4] if base.lower().endswith(".dll") else base
|
|
||||||
|
|
||||||
|
|
||||||
def parse(path):
|
|
||||||
root = ET.parse(path).getroot()
|
|
||||||
c = root.find(".//t:ResultSummary/t:Counters", NS)
|
|
||||||
if c is None:
|
|
||||||
return None
|
|
||||||
total = int(c.get("total", 0))
|
|
||||||
if total == 0: # e.g. the Integration project, filtered out of the unit run
|
|
||||||
return None
|
|
||||||
executed = int(c.get("executed", 0))
|
|
||||||
passed = int(c.get("passed", 0))
|
|
||||||
failed = int(c.get("failed", 0)) + int(c.get("error", 0))
|
|
||||||
skipped = total - executed
|
|
||||||
return {
|
|
||||||
"name": project_name(root) or os.path.basename(path),
|
|
||||||
"passed": passed, "failed": failed, "skipped": skipped, "total": total,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def main(results_dir):
|
|
||||||
rows = [r for r in (parse(p) for p in sorted(glob.glob(os.path.join(results_dir, "*.trx")))) if r]
|
|
||||||
if not rows:
|
|
||||||
print("_No test results found._")
|
|
||||||
return 0
|
|
||||||
rows.sort(key=lambda r: r["name"])
|
|
||||||
print("## ✅ Unit tests\n")
|
|
||||||
print("| Project | Result | Passed | Failed | Skipped | Total |")
|
|
||||||
print("| ------- | :----: | -----: | -----: | ------: | ----: |")
|
|
||||||
for r in rows:
|
|
||||||
status = "❌" if r["failed"] else "✅"
|
|
||||||
print(f"| {r['name']} | {status} | {r['passed']} | {r['failed']} | {r['skipped']} | {r['total']} |")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
sys.exit(main(sys.argv[1] if len(sys.argv) > 1 else "TestResults"))
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Render a per-frontend test table from vitest JSON reports for a Gitea job summary (#136).
|
|
||||||
|
|
||||||
Reads every *.json in the given directory (default: test-output), each written by an app's
|
|
||||||
`test` target (reporters: json, outputFile: {workspaceRoot}/test-output/{projectName}.json), and
|
|
||||||
prints a markdown table to stdout — one row per frontend app. The CI step redirects it into
|
|
||||||
$GITHUB_STEP_SUMMARY. Stdlib only.
|
|
||||||
"""
|
|
||||||
import glob
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
|
|
||||||
|
|
||||||
def main(results_dir):
|
|
||||||
rows = []
|
|
||||||
for path in sorted(glob.glob(os.path.join(results_dir, "*.json"))):
|
|
||||||
try:
|
|
||||||
with open(path) as fh:
|
|
||||||
d = json.load(fh)
|
|
||||||
except (OSError, ValueError):
|
|
||||||
continue
|
|
||||||
rows.append({
|
|
||||||
"name": os.path.splitext(os.path.basename(path))[0],
|
|
||||||
"passed": d.get("numPassedTests", 0),
|
|
||||||
"failed": d.get("numFailedTests", 0),
|
|
||||||
"skipped": d.get("numPendingTests", 0) + d.get("numTodoTests", 0),
|
|
||||||
"total": d.get("numTotalTests", 0),
|
|
||||||
"ok": d.get("success", False),
|
|
||||||
})
|
|
||||||
if not rows:
|
|
||||||
print("_No frontend test results found._")
|
|
||||||
return 0
|
|
||||||
print("## 🅰️ Frontend tests\n")
|
|
||||||
print("| Frontend | Result | Passed | Failed | Skipped | Total |")
|
|
||||||
print("| -------- | :----: | -----: | -----: | ------: | ----: |")
|
|
||||||
for r in rows:
|
|
||||||
status = "✅" if r["ok"] and not r["failed"] else "❌"
|
|
||||||
print(f"| {r['name']} | {status} | {r['passed']} | {r['failed']} | {r['skipped']} | {r['total']} |")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
sys.exit(main(sys.argv[1] if len(sys.argv) > 1 else "test-output"))
|
|
||||||
@@ -24,12 +24,6 @@ import {
|
|||||||
Observable
|
Observable
|
||||||
} from 'rxjs';
|
} from 'rxjs';
|
||||||
|
|
||||||
export interface BeheerDefaultFill {
|
|
||||||
bronorganisatie: string;
|
|
||||||
verantwoordelijkeOrganisatie: string;
|
|
||||||
vertrouwelijkheidaanduiding: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BeheerZaaktype {
|
export interface BeheerZaaktype {
|
||||||
identificatie: string;
|
identificatie: string;
|
||||||
omschrijving: string;
|
omschrijving: string;
|
||||||
@@ -452,69 +446,4 @@ export class BffApiV1Service {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
getBeheerDefaultFill<TData = BeheerDefaultFill>( options?: HttpClientBodyOptions): Observable<TData>;
|
|
||||||
getBeheerDefaultFill<TData = BeheerDefaultFill>( options?: HttpClientEventOptions): Observable<HttpEvent<TData>>;
|
|
||||||
getBeheerDefaultFill<TData = BeheerDefaultFill>( options?: HttpClientResponseOptions): Observable<AngularHttpResponse<TData>>;
|
|
||||||
getBeheerDefaultFill<TData = BeheerDefaultFill>(
|
|
||||||
options?: HttpClientObserveOptions): Observable<TData | HttpEvent<TData> | AngularHttpResponse<TData>> {
|
|
||||||
if (options?.observe === 'events') {
|
|
||||||
return this.http.get<TData>(
|
|
||||||
`/beheer/default-fill`,{
|
|
||||||
...(options as Omit<NonNullable<typeof options>, 'observe'>),
|
|
||||||
observe: 'events',
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options?.observe === 'response') {
|
|
||||||
return this.http.get<TData>(
|
|
||||||
`/beheer/default-fill`,{
|
|
||||||
...(options as Omit<NonNullable<typeof options>, 'observe'>),
|
|
||||||
observe: 'response',
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.http.get<TData>(
|
|
||||||
`/beheer/default-fill`,{
|
|
||||||
...(options as Omit<NonNullable<typeof options>, 'observe'>),
|
|
||||||
observe: 'body',
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
putBeheerDefaultFill<TData = void>(beheerDefaultFill: BeheerDefaultFill, options?: HttpClientBodyOptions): Observable<TData>;
|
|
||||||
putBeheerDefaultFill<TData = void>(beheerDefaultFill: BeheerDefaultFill, options?: HttpClientEventOptions): Observable<HttpEvent<TData>>;
|
|
||||||
putBeheerDefaultFill<TData = void>(beheerDefaultFill: BeheerDefaultFill, options?: HttpClientResponseOptions): Observable<AngularHttpResponse<TData>>;
|
|
||||||
putBeheerDefaultFill<TData = void>(
|
|
||||||
beheerDefaultFill: BeheerDefaultFill, options?: HttpClientObserveOptions): Observable<TData | HttpEvent<TData> | AngularHttpResponse<TData>> {
|
|
||||||
if (options?.observe === 'events') {
|
|
||||||
return this.http.put<TData>(
|
|
||||||
`/beheer/default-fill`,
|
|
||||||
beheerDefaultFill,{
|
|
||||||
...(options as Omit<NonNullable<typeof options>, 'observe'>),
|
|
||||||
observe: 'events',
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options?.observe === 'response') {
|
|
||||||
return this.http.put<TData>(
|
|
||||||
`/beheer/default-fill`,
|
|
||||||
beheerDefaultFill,{
|
|
||||||
...(options as Omit<NonNullable<typeof options>, 'observe'>),
|
|
||||||
observe: 'response',
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.http.put<TData>(
|
|
||||||
`/beheer/default-fill`,
|
|
||||||
beheerDefaultFill,{
|
|
||||||
...(options as Omit<NonNullable<typeof options>, 'observe'>),
|
|
||||||
observe: 'body',
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -33,15 +33,6 @@ builder.Services.AddSingleton(sp => sp.GetRequiredService<IConfiguration>()
|
|||||||
builder.Services.AddSingleton(sp => sp.GetRequiredService<IConfiguration>()
|
builder.Services.AddSingleton(sp => sp.GetRequiredService<IConfiguration>()
|
||||||
.GetSection("Acl:OpenZaak").Get<OpenZaakOptions>()
|
.GetSection("Acl:OpenZaak").Get<OpenZaakOptions>()
|
||||||
?? throw new InvalidOperationException("Missing configuration section 'Acl:OpenZaak'"));
|
?? throw new InvalidOperationException("Missing configuration section 'Acl:OpenZaak'"));
|
||||||
// The default-fill values are held in a runtime-mutable store (S-15b, ADR-0026), seeded from the
|
|
||||||
// configured Acl:Defaults. The beheer portal edits it; the worker reads it per zaak. The S-27
|
|
||||||
// resolution keys stay on AclDefaults (static) — see DefaultFillSettings.
|
|
||||||
builder.Services.AddSingleton<IDefaultFillStore>(sp =>
|
|
||||||
{
|
|
||||||
var d = sp.GetRequiredService<AclDefaults>();
|
|
||||||
return new InMemoryDefaultFillStore(
|
|
||||||
new DefaultFillSettings(d.Bronorganisatie, d.VerantwoordelijkeOrganisatie, d.Vertrouwelijkheidaanduiding));
|
|
||||||
});
|
|
||||||
builder.Services.AddHttpClient<IZaakGateway, OpenZaakGateway>();
|
builder.Services.AddHttpClient<IZaakGateway, OpenZaakGateway>();
|
||||||
// Singleton so the resolved zaaktype/informatieobjecttype URLs are cached across requests (S-27).
|
// Singleton so the resolved zaaktype/informatieobjecttype URLs are cached across requests (S-27).
|
||||||
builder.Services.AddSingleton<IZaaktypeCatalog, CachedZaaktypeCatalog>();
|
builder.Services.AddSingleton<IZaaktypeCatalog, CachedZaaktypeCatalog>();
|
||||||
@@ -100,22 +91,6 @@ app.MapPost("/documenten", async (StoreDocumentRequest body, AclService acl, Can
|
|||||||
app.MapGet("/catalogi/zaaktypen", async (AclService acl, CancellationToken ct) =>
|
app.MapGet("/catalogi/zaaktypen", async (AclService acl, CancellationToken ct) =>
|
||||||
Results.Ok(await acl.ListZaaktypenAsync(ct)));
|
Results.Ok(await acl.ListZaaktypenAsync(ct)));
|
||||||
|
|
||||||
// Read the current default-fill settings (beheer config viewer, S-15b).
|
|
||||||
app.MapGet("/default-fill", (AclService acl) => Results.Ok(acl.GetDefaultFill()));
|
|
||||||
|
|
||||||
// Update the default-fill settings from the beheer portal (S-15b). Behind beheerder authorization at
|
|
||||||
// the BFF; the ACL validates the values are present (the three ZGW-mandatory fields).
|
|
||||||
app.MapPut("/default-fill", (DefaultFillSettings body, AclService acl) =>
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(body.Bronorganisatie) ||
|
|
||||||
string.IsNullOrWhiteSpace(body.VerantwoordelijkeOrganisatie) ||
|
|
||||||
string.IsNullOrWhiteSpace(body.Vertrouwelijkheidaanduiding))
|
|
||||||
return Results.BadRequest(new { error = "bronorganisatie, verantwoordelijkeOrganisatie and vertrouwelijkheidaanduiding are all required." });
|
|
||||||
|
|
||||||
acl.UpdateDefaultFill(body);
|
|
||||||
return Results.NoContent();
|
|
||||||
});
|
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
|
|
||||||
public sealed record OpenZaakRequest(string Bsn, string Reference);
|
public sealed record OpenZaakRequest(string Bsn, string Reference);
|
||||||
|
|||||||
@@ -2,15 +2,12 @@ namespace Acl.Application;
|
|||||||
|
|
||||||
/// <summary>The ACL's single operation: open a zaak from a domain payload,
|
/// <summary>The ACL's single operation: open a zaak from a domain payload,
|
||||||
/// default-filling the ZGW-mandatory fields (ADR-0003).</summary>
|
/// default-filling the ZGW-mandatory fields (ADR-0003).</summary>
|
||||||
public sealed class AclService(IZaakGateway gateway, IDefaultFillStore fill, IZaaktypeCatalog catalog, IClock clock)
|
public sealed class AclService(IZaakGateway gateway, AclDefaults defaults, IZaaktypeCatalog catalog, IClock clock)
|
||||||
{
|
{
|
||||||
public async Task<Uri> OpenZaakAsync(DomainRegistration registration, CancellationToken ct = default)
|
public async Task<Uri> OpenZaakAsync(DomainRegistration registration, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(registration);
|
ArgumentNullException.ThrowIfNull(registration);
|
||||||
|
|
||||||
// Read the current default-fill per zaak (not at construction), so a beheerder edit (S-15b)
|
|
||||||
// takes effect on the next zaak without a restart.
|
|
||||||
var defaults = fill.Current;
|
|
||||||
var request = new ZaakRequest(
|
var request = new ZaakRequest(
|
||||||
defaults.Bronorganisatie,
|
defaults.Bronorganisatie,
|
||||||
defaults.VerantwoordelijkeOrganisatie,
|
defaults.VerantwoordelijkeOrganisatie,
|
||||||
@@ -50,17 +47,6 @@ public sealed class AclService(IZaakGateway gateway, IDefaultFillStore fill, IZa
|
|||||||
public Task<IReadOnlyList<ZaaktypeSummary>> ListZaaktypenAsync(CancellationToken ct = default) =>
|
public Task<IReadOnlyList<ZaaktypeSummary>> ListZaaktypenAsync(CancellationToken ct = default) =>
|
||||||
gateway.ListZaaktypenAsync(ct);
|
gateway.ListZaaktypenAsync(ct);
|
||||||
|
|
||||||
/// <summary>The current default-fill settings, for the beheer config viewer (S-15b).</summary>
|
|
||||||
public DefaultFillSettings GetDefaultFill() => fill.Current;
|
|
||||||
|
|
||||||
/// <summary>Replace the default-fill settings from the beheer portal (S-15b). Takes effect on the
|
|
||||||
/// next zaak (the fill is read per zaak, not cached).</summary>
|
|
||||||
public void UpdateDefaultFill(DefaultFillSettings settings)
|
|
||||||
{
|
|
||||||
ArgumentNullException.ThrowIfNull(settings);
|
|
||||||
fill.Update(settings);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>The zaak's reference (its ZGW identificatie), for the read projection (#78).</summary>
|
/// <summary>The zaak's reference (its ZGW identificatie), for the read projection (#78).</summary>
|
||||||
public Task<string> GetZaakReferenceAsync(Uri zaakUrl, CancellationToken ct = default)
|
public Task<string> GetZaakReferenceAsync(Uri zaakUrl, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
@@ -82,7 +68,6 @@ public sealed class AclService(IZaakGateway gateway, IDefaultFillStore fill, IZa
|
|||||||
ArgumentException.ThrowIfNullOrWhiteSpace(fileName);
|
ArgumentException.ThrowIfNullOrWhiteSpace(fileName);
|
||||||
ArgumentException.ThrowIfNullOrWhiteSpace(contentType);
|
ArgumentException.ThrowIfNullOrWhiteSpace(contentType);
|
||||||
|
|
||||||
var defaults = fill.Current;
|
|
||||||
var request = new DocumentRequest(
|
var request = new DocumentRequest(
|
||||||
defaults.Bronorganisatie,
|
defaults.Bronorganisatie,
|
||||||
await catalog.GetInformatieobjecttypeUrlAsync(ct),
|
await catalog.GetInformatieobjecttypeUrlAsync(ct),
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
namespace Acl.Application;
|
|
||||||
|
|
||||||
/// <summary>The ZGW default-fill values a beheerder can edit at runtime (S-15b) — the mandatory fields
|
|
||||||
/// the ACL stamps on every zaak (ADR-0003). The S-27 catalog-resolution keys (zaaktype identificatie,
|
|
||||||
/// informatieobjecttype omschrijving) stay static config: editing them would desync the resolved-URL
|
|
||||||
/// cache, and they're catalogus wiring rather than "default fill".</summary>
|
|
||||||
public sealed record DefaultFillSettings(
|
|
||||||
string Bronorganisatie,
|
|
||||||
string VerantwoordelijkeOrganisatie,
|
|
||||||
string Vertrouwelijkheidaanduiding);
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
namespace Acl.Application;
|
|
||||||
|
|
||||||
/// <summary>Holds the ACL's current default-fill values, editable at runtime through the beheer portal
|
|
||||||
/// (S-15b). Seeded from config at startup.
|
|
||||||
///
|
|
||||||
/// ponytail: in-memory only — an edit is lost on restart, when it reverts to the configured env
|
|
||||||
/// (ADR-0026). Adequate for the reference demo; back it with a DB if durable, audited config is needed.
|
|
||||||
/// </summary>
|
|
||||||
public interface IDefaultFillStore
|
|
||||||
{
|
|
||||||
DefaultFillSettings Current { get; }
|
|
||||||
|
|
||||||
void Update(DefaultFillSettings settings);
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
namespace Acl.Application;
|
|
||||||
|
|
||||||
/// <summary>In-memory <see cref="IDefaultFillStore"/> (ADR-0026), seeded from config. Thread-safe: the
|
|
||||||
/// hosted worker reads <see cref="Current"/> per zaak while the beheer endpoint may update it.</summary>
|
|
||||||
public sealed class InMemoryDefaultFillStore(DefaultFillSettings seed) : IDefaultFillStore
|
|
||||||
{
|
|
||||||
private readonly object _gate = new();
|
|
||||||
private DefaultFillSettings _current = seed;
|
|
||||||
|
|
||||||
public DefaultFillSettings Current
|
|
||||||
{
|
|
||||||
get { lock (_gate) return _current; }
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Update(DefaultFillSettings settings)
|
|
||||||
{
|
|
||||||
ArgumentNullException.ThrowIfNull(settings);
|
|
||||||
lock (_gate) _current = settings;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -84,11 +84,8 @@ public class AclServiceTests
|
|||||||
InformatieobjecttypeOmschrijving = "Diploma",
|
InformatieobjecttypeOmschrijving = "Diploma",
|
||||||
};
|
};
|
||||||
|
|
||||||
private static InMemoryDefaultFillStore FillFrom(AclDefaults d) =>
|
|
||||||
new(new DefaultFillSettings(d.Bronorganisatie, d.VerantwoordelijkeOrganisatie, d.Vertrouwelijkheidaanduiding));
|
|
||||||
|
|
||||||
private static AclService ServiceWith(FakeGateway gateway, AclDefaults defaults, DateOnly today) =>
|
private static AclService ServiceWith(FakeGateway gateway, AclDefaults defaults, DateOnly today) =>
|
||||||
new(gateway, FillFrom(defaults), new CachedZaaktypeCatalog(gateway, defaults), new FixedClock(today));
|
new(gateway, defaults, new CachedZaaktypeCatalog(gateway, defaults), new FixedClock(today));
|
||||||
|
|
||||||
private sealed class FixedClock(DateOnly today) : IClock
|
private sealed class FixedClock(DateOnly today) : IClock
|
||||||
{
|
{
|
||||||
@@ -116,22 +113,6 @@ public class AclServiceTests
|
|||||||
Assert.Equal("reg-77", req.Identificatie);
|
Assert.Equal("reg-77", req.Identificatie);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Opening_a_zaak_reflects_a_default_fill_update(/* S-15b */)
|
|
||||||
{
|
|
||||||
var gateway = new FakeGateway();
|
|
||||||
var service = ServiceWith(gateway, Defaults(), new DateOnly(2026, 6, 4));
|
|
||||||
|
|
||||||
// A beheerder edits the default-fill; the very next zaak must use the new values (read per zaak).
|
|
||||||
service.UpdateDefaultFill(new DefaultFillSettings("999999999", "888888888", "vertrouwelijk"));
|
|
||||||
await service.OpenZaakAsync(new DomainRegistration("123456782", "reg-1"));
|
|
||||||
|
|
||||||
var req = gateway.Captured!;
|
|
||||||
Assert.Equal("999999999", req.Bronorganisatie);
|
|
||||||
Assert.Equal("888888888", req.VerantwoordelijkeOrganisatie);
|
|
||||||
Assert.Equal("vertrouwelijk", req.Vertrouwelijkheidaanduiding);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Rejects_a_null_registration_without_calling_the_gateway()
|
public async Task Rejects_a_null_registration_without_calling_the_gateway()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
using Acl.Application;
|
|
||||||
|
|
||||||
namespace Acl.Tests;
|
|
||||||
|
|
||||||
public class DefaultFillStoreTests
|
|
||||||
{
|
|
||||||
private static DefaultFillSettings Seed() => new("517439943", "517439943", "openbaar");
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Seeds_from_the_supplied_settings()
|
|
||||||
{
|
|
||||||
var store = new InMemoryDefaultFillStore(Seed());
|
|
||||||
|
|
||||||
Assert.Equal("517439943", store.Current.Bronorganisatie);
|
|
||||||
Assert.Equal("openbaar", store.Current.Vertrouwelijkheidaanduiding);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Updating_replaces_the_current_settings()
|
|
||||||
{
|
|
||||||
var store = new InMemoryDefaultFillStore(Seed());
|
|
||||||
|
|
||||||
store.Update(new DefaultFillSettings("999999999", "888888888", "vertrouwelijk"));
|
|
||||||
|
|
||||||
Assert.Equal("999999999", store.Current.Bronorganisatie);
|
|
||||||
Assert.Equal("888888888", store.Current.VerantwoordelijkeOrganisatie);
|
|
||||||
Assert.Equal("vertrouwelijk", store.Current.Vertrouwelijkheidaanduiding);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
"stryker-config": {
|
"stryker-config": {
|
||||||
"solution": "Acl.slnx",
|
"solution": "Acl.slnx",
|
||||||
"test-projects": ["Acl.Tests/Acl.Tests.csproj"],
|
"test-projects": ["Acl.Tests/Acl.Tests.csproj"],
|
||||||
"reporters": ["progress", "html", "markdown"],
|
"reporters": ["progress", "html"],
|
||||||
"thresholds": {
|
"thresholds": {
|
||||||
"high": 95,
|
"high": 95,
|
||||||
"low": 90,
|
"low": 90,
|
||||||
|
|||||||
@@ -59,26 +59,12 @@ public interface IProjectionClient
|
|||||||
/// internal reference, not shown in the portal.</summary>
|
/// internal reference, not shown in the portal.</summary>
|
||||||
public sealed record BeheerZaaktype(string Identificatie, string Omschrijving);
|
public sealed record BeheerZaaktype(string Identificatie, string Omschrijving);
|
||||||
|
|
||||||
/// <summary>The ACL default-fill settings the beheer portal reads + edits (S-15b): the three ZGW-mandatory
|
/// <summary>Port to the ACL for read-only catalogus queries (beheer portal, S-15a). The BFF reaches the
|
||||||
/// fields the ACL stamps on every zaak (ADR-0003).</summary>
|
/// ACL directly for this read: the catalogus isn't a domain concern, and the ACL is the only code
|
||||||
public sealed record BeheerDefaultFill(
|
/// allowed to read the ZGW Catalogi API (§8.1, ADR-0025).</summary>
|
||||||
string Bronorganisatie,
|
|
||||||
string VerantwoordelijkeOrganisatie,
|
|
||||||
string Vertrouwelijkheidaanduiding);
|
|
||||||
|
|
||||||
/// <summary>Port to the ACL for beheer queries (beheer portal). The BFF reaches the ACL directly: these
|
|
||||||
/// aren't a domain concern, and the ACL is the only code allowed to read/own the ZGW-facing config
|
|
||||||
/// (§8.1, ADR-0025).</summary>
|
|
||||||
public interface IAclClient
|
public interface IAclClient
|
||||||
{
|
{
|
||||||
/// <summary>The published catalogus zaaktypen, read-only (S-15a).</summary>
|
|
||||||
Task<IReadOnlyList<BeheerZaaktype>> GetZaaktypenAsync(CancellationToken ct = default);
|
Task<IReadOnlyList<BeheerZaaktype>> GetZaaktypenAsync(CancellationToken ct = default);
|
||||||
|
|
||||||
/// <summary>The current default-fill settings (S-15b).</summary>
|
|
||||||
Task<BeheerDefaultFill> GetDefaultFillAsync(CancellationToken ct = default);
|
|
||||||
|
|
||||||
/// <summary>Replace the default-fill settings (S-15b).</summary>
|
|
||||||
Task UpdateDefaultFillAsync(BeheerDefaultFill settings, CancellationToken ct = default);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Calls the Domain Service's <c>POST /registrations</c>.</summary>
|
/// <summary>Calls the Domain Service's <c>POST /registrations</c>.</summary>
|
||||||
@@ -155,14 +141,4 @@ public sealed class AclClient(HttpClient http) : IAclClient
|
|||||||
{
|
{
|
||||||
public async Task<IReadOnlyList<BeheerZaaktype>> GetZaaktypenAsync(CancellationToken ct = default)
|
public async Task<IReadOnlyList<BeheerZaaktype>> GetZaaktypenAsync(CancellationToken ct = default)
|
||||||
=> await http.GetFromJsonAsync<List<BeheerZaaktype>>("catalogi/zaaktypen", ct) ?? [];
|
=> await http.GetFromJsonAsync<List<BeheerZaaktype>>("catalogi/zaaktypen", ct) ?? [];
|
||||||
|
|
||||||
public async Task<BeheerDefaultFill> GetDefaultFillAsync(CancellationToken ct = default)
|
|
||||||
=> await http.GetFromJsonAsync<BeheerDefaultFill>("default-fill", ct)
|
|
||||||
?? throw new InvalidOperationException("The ACL returned an empty default-fill response.");
|
|
||||||
|
|
||||||
public async Task UpdateDefaultFillAsync(BeheerDefaultFill settings, CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
using var response = await http.PutAsJsonAsync("default-fill", settings, ct);
|
|
||||||
response.EnsureSuccessStatusCode();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -228,25 +228,6 @@ app.MapGet("/beheer/catalogi/zaaktypen", async (IAclClient acl, CancellationToke
|
|||||||
.Produces(StatusCodes.Status401Unauthorized)
|
.Produces(StatusCodes.Status401Unauthorized)
|
||||||
.Produces(StatusCodes.Status403Forbidden);
|
.Produces(StatusCodes.Status403Forbidden);
|
||||||
|
|
||||||
// Beheer default-fill config (S-15b): read + edit the ACL's default-fill values. Behind medewerker-
|
|
||||||
// realm + beheerder authorization; the BFF proxies the ACL (ADR-0025). The ACL validates the values.
|
|
||||||
app.MapGet("/beheer/default-fill", async (IAclClient acl, CancellationToken ct) =>
|
|
||||||
Results.Ok(await acl.GetDefaultFillAsync(ct)))
|
|
||||||
.RequireAuthorization(BeheerAuth.Policy)
|
|
||||||
.Produces<BeheerDefaultFill>(StatusCodes.Status200OK)
|
|
||||||
.Produces(StatusCodes.Status401Unauthorized)
|
|
||||||
.Produces(StatusCodes.Status403Forbidden);
|
|
||||||
|
|
||||||
app.MapPut("/beheer/default-fill", async (BeheerDefaultFill body, IAclClient acl, CancellationToken ct) =>
|
|
||||||
{
|
|
||||||
await acl.UpdateDefaultFillAsync(body, ct);
|
|
||||||
return Results.NoContent();
|
|
||||||
})
|
|
||||||
.RequireAuthorization(BeheerAuth.Policy)
|
|
||||||
.Produces(StatusCodes.Status204NoContent)
|
|
||||||
.Produces(StatusCodes.Status401Unauthorized)
|
|
||||||
.Produces(StatusCodes.Status403Forbidden);
|
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
|
|
||||||
/// <summary>The behandelaar's decision on a registration.</summary>
|
/// <summary>The behandelaar's decision on a registration.</summary>
|
||||||
|
|||||||
@@ -1,84 +0,0 @@
|
|||||||
using System.Net;
|
|
||||||
using System.Net.Http.Headers;
|
|
||||||
using System.Net.Http.Json;
|
|
||||||
using Bff.Api;
|
|
||||||
|
|
||||||
namespace Bff.Tests;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The beheer default-fill config endpoints (S-15b): read (GET) and edit (PUT) the ACL's default-fill,
|
|
||||||
/// reached only with a medewerker-realm token carrying the <c>beheerder</c> role. Missing token → 401;
|
|
||||||
/// a medewerker without the role → 403; a beheerder reads and updates via the ACL client.
|
|
||||||
/// </summary>
|
|
||||||
public class BeheerDefaultFillEndpointTests
|
|
||||||
{
|
|
||||||
private static HttpRequestMessage Get(string? bearer)
|
|
||||||
{
|
|
||||||
var r = new HttpRequestMessage(HttpMethod.Get, "/beheer/default-fill");
|
|
||||||
if (bearer is not null) r.Headers.Authorization = new AuthenticationHeaderValue("Bearer", bearer);
|
|
||||||
return r;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static HttpRequestMessage Put(string? bearer, object body)
|
|
||||||
{
|
|
||||||
var r = new HttpRequestMessage(HttpMethod.Put, "/beheer/default-fill") { Content = JsonContent.Create(body) };
|
|
||||||
if (bearer is not null) r.Headers.Authorization = new AuthenticationHeaderValue("Bearer", bearer);
|
|
||||||
return r;
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Rejects_read_without_a_token()
|
|
||||||
{
|
|
||||||
using var factory = new BffFactory();
|
|
||||||
var response = await factory.CreateClient().SendAsync(Get(bearer: null));
|
|
||||||
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Rejects_a_medewerker_without_the_beheerder_role()
|
|
||||||
{
|
|
||||||
using var factory = new BffFactory();
|
|
||||||
var response = await factory.CreateClient().SendAsync(Get(TestTokens.Medewerker("behandelaar")));
|
|
||||||
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Serves_the_current_default_fill_to_a_beheerder()
|
|
||||||
{
|
|
||||||
using var factory = new BffFactory();
|
|
||||||
factory.Acl.DefaultFill = new BeheerDefaultFill("517439943", "517439943", "openbaar");
|
|
||||||
|
|
||||||
var response = await factory.CreateClient().SendAsync(Get(TestTokens.Medewerker("beheerder")));
|
|
||||||
|
|
||||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
|
||||||
var body = await response.Content.ReadFromJsonAsync<BeheerDefaultFill>();
|
|
||||||
Assert.Equal("517439943", body!.Bronorganisatie);
|
|
||||||
Assert.Equal("openbaar", body.Vertrouwelijkheidaanduiding);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Updates_the_default_fill_via_the_acl_for_a_beheerder()
|
|
||||||
{
|
|
||||||
using var factory = new BffFactory();
|
|
||||||
|
|
||||||
var response = await factory.CreateClient().SendAsync(
|
|
||||||
Put(TestTokens.Medewerker("beheerder"),
|
|
||||||
new { bronorganisatie = "999999999", verantwoordelijkeOrganisatie = "888888888", vertrouwelijkheidaanduiding = "vertrouwelijk" }));
|
|
||||||
|
|
||||||
Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);
|
|
||||||
Assert.Equal("999999999", factory.Acl.Updated!.Bronorganisatie);
|
|
||||||
Assert.Equal("vertrouwelijk", factory.Acl.Updated.Vertrouwelijkheidaanduiding);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Rejects_an_update_from_a_non_beheerder()
|
|
||||||
{
|
|
||||||
using var factory = new BffFactory();
|
|
||||||
|
|
||||||
var response = await factory.CreateClient().SendAsync(
|
|
||||||
Put(TestTokens.Medewerker("behandelaar"), new { bronorganisatie = "1", verantwoordelijkeOrganisatie = "2", vertrouwelijkheidaanduiding = "openbaar" }));
|
|
||||||
|
|
||||||
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
|
||||||
Assert.Null(factory.Acl.Updated);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -142,24 +142,11 @@ internal sealed class FakeProjectionClient : IProjectionClient
|
|||||||
=> Task.FromResult<IReadOnlyList<ProjectionEntry>>(Entries);
|
=> Task.FromResult<IReadOnlyList<ProjectionEntry>>(Entries);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Serves catalogus zaaktypen (S-15a) and holds the default-fill settings (S-15b).</summary>
|
/// <summary>Serves a configurable set of catalogus zaaktypen (beheer viewer, S-15a).</summary>
|
||||||
internal sealed class FakeAclClient : IAclClient
|
internal sealed class FakeAclClient : IAclClient
|
||||||
{
|
{
|
||||||
public List<BeheerZaaktype> Zaaktypen { get; } = [];
|
public List<BeheerZaaktype> Zaaktypen { get; } = [];
|
||||||
|
|
||||||
public Task<IReadOnlyList<BeheerZaaktype>> GetZaaktypenAsync(CancellationToken ct = default)
|
public Task<IReadOnlyList<BeheerZaaktype>> GetZaaktypenAsync(CancellationToken ct = default)
|
||||||
=> Task.FromResult<IReadOnlyList<BeheerZaaktype>>(Zaaktypen);
|
=> Task.FromResult<IReadOnlyList<BeheerZaaktype>>(Zaaktypen);
|
||||||
|
|
||||||
public BeheerDefaultFill DefaultFill { get; set; } = new("517439943", "517439943", "openbaar");
|
|
||||||
public BeheerDefaultFill? Updated { get; private set; }
|
|
||||||
|
|
||||||
public Task<BeheerDefaultFill> GetDefaultFillAsync(CancellationToken ct = default)
|
|
||||||
=> Task.FromResult(DefaultFill);
|
|
||||||
|
|
||||||
public Task UpdateDefaultFillAsync(BeheerDefaultFill settings, CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
Updated = settings;
|
|
||||||
DefaultFill = settings;
|
|
||||||
return Task.CompletedTask;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -255,80 +255,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"/beheer/default-fill": {
|
|
||||||
"get": {
|
|
||||||
"tags": [
|
|
||||||
"Bff.Api"
|
|
||||||
],
|
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"description": "OK",
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/BeheerDefaultFill"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"401": {
|
|
||||||
"description": "Unauthorized"
|
|
||||||
},
|
|
||||||
"403": {
|
|
||||||
"description": "Forbidden"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"put": {
|
|
||||||
"tags": [
|
|
||||||
"Bff.Api"
|
|
||||||
],
|
|
||||||
"requestBody": {
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/BeheerDefaultFill"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": true
|
|
||||||
},
|
|
||||||
"responses": {
|
|
||||||
"204": {
|
|
||||||
"description": "No Content"
|
|
||||||
},
|
|
||||||
"401": {
|
|
||||||
"description": "Unauthorized"
|
|
||||||
},
|
|
||||||
"403": {
|
|
||||||
"description": "Forbidden"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"components": {
|
"components": {
|
||||||
"schemas": {
|
"schemas": {
|
||||||
"BeheerDefaultFill": {
|
|
||||||
"required": [
|
|
||||||
"bronorganisatie",
|
|
||||||
"verantwoordelijkeOrganisatie",
|
|
||||||
"vertrouwelijkheidaanduiding"
|
|
||||||
],
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"bronorganisatie": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"verantwoordelijkeOrganisatie": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"vertrouwelijkheidaanduiding": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"BeheerZaaktype": {
|
"BeheerZaaktype": {
|
||||||
"required": [
|
"required": [
|
||||||
"identificatie",
|
"identificatie",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"stryker-config": {
|
"stryker-config": {
|
||||||
"solution": "Bff.slnx",
|
"solution": "Bff.slnx",
|
||||||
"test-projects": ["Bff.Tests/Bff.Tests.csproj"],
|
"test-projects": ["Bff.Tests/Bff.Tests.csproj"],
|
||||||
"reporters": ["progress", "html", "markdown"],
|
"reporters": ["progress", "html"],
|
||||||
"mutate": [
|
"mutate": [
|
||||||
"!**/Program.cs",
|
"!**/Program.cs",
|
||||||
"!**/DownstreamClients.cs"
|
"!**/DownstreamClients.cs"
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"stryker-config": {
|
"stryker-config": {
|
||||||
"solution": "Big.slnx",
|
"solution": "Big.slnx",
|
||||||
"test-projects": ["Big.Tests/Big.Tests.csproj"],
|
"test-projects": ["Big.Tests/Big.Tests.csproj"],
|
||||||
"reporters": ["progress", "html", "markdown"],
|
"reporters": ["progress", "html"],
|
||||||
"mutate": [
|
"mutate": [
|
||||||
"!**/OpenZaakJobPump.cs",
|
"!**/OpenZaakJobPump.cs",
|
||||||
"!**/BeoordelingEscalatiePump.cs",
|
"!**/BeoordelingEscalatiePump.cs",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"stryker-config": {
|
"stryker-config": {
|
||||||
"solution": "EventSubscriber.slnx",
|
"solution": "EventSubscriber.slnx",
|
||||||
"test-projects": ["EventSubscriber.Tests/EventSubscriber.Tests.csproj"],
|
"test-projects": ["EventSubscriber.Tests/EventSubscriber.Tests.csproj"],
|
||||||
"reporters": ["progress", "html", "markdown"],
|
"reporters": ["progress", "html"],
|
||||||
"thresholds": {
|
"thresholds": {
|
||||||
"high": 95,
|
"high": 95,
|
||||||
"low": 90,
|
"low": 90,
|
||||||
|
|||||||
@@ -44,9 +44,7 @@ public sealed class EenZaakOpenenSteps
|
|||||||
[When("the domain asks the ACL to open a zaak")]
|
[When("the domain asks the ACL to open a zaak")]
|
||||||
public async Task WhenTheDomainAsksTheAclToOpenAZaak()
|
public async Task WhenTheDomainAsksTheAclToOpenAZaak()
|
||||||
{
|
{
|
||||||
var fill = new InMemoryDefaultFillStore(new DefaultFillSettings(
|
var service = new AclService(_gateway, _defaults!, new CachedZaaktypeCatalog(_gateway, _defaults!), new FixedClock(_today));
|
||||||
_defaults!.Bronorganisatie, _defaults.VerantwoordelijkeOrganisatie, _defaults.Vertrouwelijkheidaanduiding));
|
|
||||||
var service = new AclService(_gateway, fill, new CachedZaaktypeCatalog(_gateway, _defaults!), new FixedClock(_today));
|
|
||||||
_returnedUrl = await service.OpenZaakAsync(_registration!);
|
_returnedUrl = await service.OpenZaakAsync(_registration!);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,26 +0,0 @@
|
|||||||
import { expect, test } from '@playwright/test';
|
|
||||||
|
|
||||||
// S-15b: a beheerder edits the ACL default-fill in the beheer portal and gets a saved confirmation.
|
|
||||||
// Runs against the shared verify stack; it edits + saves (the ACL store is in-memory, ADR-0026) and
|
|
||||||
// asserts the confirmation, without depending on another test's state.
|
|
||||||
test('a beheerder edits and saves the default-fill', async ({ page }) => {
|
|
||||||
await page.goto('http://beheer/');
|
|
||||||
|
|
||||||
// Keycloak medewerker-realm login (same realm as behandel).
|
|
||||||
await page.locator('#username').fill('bram-beheerder');
|
|
||||||
await page.locator('#password').fill('test123');
|
|
||||||
await page.locator('#kc-login').click();
|
|
||||||
|
|
||||||
await expect(page.getByRole('heading', { name: /Catalogus/i })).toBeVisible();
|
|
||||||
|
|
||||||
// Navigate to the default-fill editor and change a value.
|
|
||||||
await page.getByRole('link', { name: /Default-fill/i }).click();
|
|
||||||
await expect(page.getByRole('heading', { name: /Default-fill/i })).toBeVisible();
|
|
||||||
|
|
||||||
const bron = page.getByLabel('Bronorganisatie');
|
|
||||||
await expect(bron).toBeVisible();
|
|
||||||
await bron.fill('517439943');
|
|
||||||
await page.getByRole('button', { name: /Opslaan/i }).click();
|
|
||||||
|
|
||||||
await expect(page.getByText(/standaardwaarden zijn opgeslagen/i)).toBeVisible();
|
|
||||||
});
|
|
||||||
@@ -21,9 +21,7 @@ export default defineConfig({
|
|||||||
// OOM-killed mid-action ("Page crashed") — fixing the flakiness at its source rather than leaning
|
// OOM-killed mid-action ("Page crashed") — fixing the flakiness at its source rather than leaning
|
||||||
// on `retries` (CLAUDE.md §15). Only two long-running happy-path specs, so serial costs little.
|
// on `retries` (CLAUDE.md §15). Only two long-running happy-path specs, so serial costs little.
|
||||||
workers: 1,
|
workers: 1,
|
||||||
// `list` for the live log; `json` (→ /e2e/playwright-report.json in the container) is copied out
|
reporter: [['list']],
|
||||||
// by run-e2e-check.sh and rendered as a per-spec table in the CI job summary (#136).
|
|
||||||
reporter: [['list'], ['json', { outputFile: 'playwright-report.json' }]],
|
|
||||||
use: {
|
use: {
|
||||||
baseURL,
|
baseURL,
|
||||||
trace: 'on-first-retry',
|
trace: 'on-first-retry',
|
||||||
|
|||||||
Reference in New Issue
Block a user