Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ export function Configurations({
> | null>(null);
const active = useRef(false);
const controller = useRef<AbortController | null>(null);
const listRequest = useRef(0);

useEffect(() => {
if (!session) return;
Expand All @@ -118,14 +119,16 @@ export function Configurations({
);
})
.catch(() => {});
const request = ++listRequest.current;
void fetch("/api/v1/configurations", { signal: lifetime.signal })
.then(checked)
.then((response) => response.json())
.then((body) => {
if (!lifetime.signal.aborted) setConfigurations(body.configurations);
if (!lifetime.signal.aborted && request === listRequest.current)
setConfigurations(body.configurations);
})
.catch(() => {
if (!lifetime.signal.aborted)
if (!lifetime.signal.aborted && request === listRequest.current)
setError(
"Konfigurationen konnten nicht geladen werden. Bitte aktualisieren.",
);
Expand Down Expand Up @@ -177,13 +180,15 @@ export function Configurations({
}, [session, compact]);

async function refresh() {
const request = ++listRequest.current;
const signal = controller.current?.signal ?? null;
const response = await checked(
await fetch("/api/v1/configurations", {
signal: controller.current?.signal ?? null,
signal,
}),
);
const body = await response.json();
if (!controller.current?.signal.aborted)
if (!signal?.aborted && request === listRequest.current)
setConfigurations(body.configurations);
}

Expand Down
6 changes: 3 additions & 3 deletions landing-zone-configurator/app/apps/web/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ function App() {
setDeploymentSelection(null);
}, [session?.user.id, session?.user.login, session?.tenant?.id]);
const [draftEpoch, setDraftEpoch] = useState(0);
const [workspaceEpoch, setWorkspaceEpoch] = useState(0);
const [configurationBinding, setConfigurationBinding] =
useState<ConfigurationBinding | null>(null);
const [draft, setDraft] = useState<EditorDraft | null>(restoredDraft);
Expand Down Expand Up @@ -115,6 +116,7 @@ function App() {
setDraft(null);
setConfigurationBinding(null);
setSavedDraft(null);
setWorkspaceEpoch((value) => value + 1);
}
activeAccount.current = key;
setSession(next);
Expand Down Expand Up @@ -673,9 +675,7 @@ function App() {
/>
)}
{platformAccess && (
<CloudCatalogueProvider
key={`catalogues:${workspaceKey(session) ?? "guest"}`}
>
<CloudCatalogueProvider key={`catalogues:${workspaceEpoch}`}>
<div hidden={view !== "editor"}>
<CloudCataloguePanel
session={session}
Expand Down
81 changes: 70 additions & 11 deletions landing-zone-configurator/app/e2e/configurator.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
type Template,
upgradeEditorDraft,
} from "@lzc/domain";
import type { Route } from "@playwright/test";
import {
applyAssistantChanges,
validateDialogue,
Expand Down Expand Up @@ -2051,16 +2052,23 @@ test("database configurations save, reopen, protect revisions and delete without
page,
}, testInfo) => {
page.on("dialog", (dialog) => void dialog.accept());
await page.route("**/api/v1/session", (route) =>
route.fulfill({
json: {
user: { id: "alice-id", login: "alice" },
csrfToken: "csrf-test",
tenant: { id: "personal", kind: "personal", roles: [] },
expiresAt: new Date(Date.now() + 3600000).toISOString(),
},
}),
);
let deferSession = true;
const sessionLookup: { route: Route | null } = { route: null };
const sessionBody = {
user: { id: "alice-id", login: "alice" },
csrfToken: "csrf-test",
tenant: { id: "personal", kind: "personal", roles: [] },
expiresAt: new Date(Date.now() + 3600000).toISOString(),
};
await page.route("**/api/v1/session", (route) => {
if (deferSession) {
sessionLookup.route = route;
return;
}
return route.fulfill({
json: sessionBody,
});
});
await page.route("**/auth/status", (route) =>
route.fulfill({
json: { github: false, stackit: true, primary: "stackit" },
Expand All @@ -2077,16 +2085,23 @@ test("database configurations save, reopen, protect revisions and delete without
draft: ReturnType<typeof createDraft>;
};
const records: Stored[] = [];
const staleLists: Route[] = [];
let initialListsReleased = false;
let conflict = false;
await page.route("**/api/v1/configurations", async (route) => {
if (route.request().method() === "GET")
if (route.request().method() === "GET") {
if (!records.length && !initialListsReleased) {
staleLists.push(route);
return;
}
return route.fulfill({
json: {
configurations: records.map(
({ draft: _draft, ...summary }) => summary,
),
},
});
}
expect(route.request().headers()["x-lzc-csrf"]).toBe("csrf-test");
const { draft } = route.request().postDataJSON();
const stored = {
Expand Down Expand Up @@ -2126,11 +2141,37 @@ test("database configurations save, reopen, protect revisions and delete without
stored.revision++;
return route.fulfill({ json: { configuration: stored } });
});
const initialSessionRequested = page.waitForRequest("**/api/v1/session");
await page.goto("/templates");
await initialSessionRequested;
const sessionRequested = page.waitForRequest("**/api/v1/session");
await restoreLegacy(page);
await sessionRequested;
const name = page.getByRole("textbox", {
name: "Name der Konfiguration",
exact: true,
});
await name.focus();
const focusedInput = await name.elementHandle();
const catalogueStarted = page.waitForRequest(
"**/api/v1/cloud-catalogues/automatic",
);
deferSession = false;
if (!sessionLookup.route)
throw new Error("Session lookup was not intercepted");
await sessionLookup.route.fulfill({ json: sessionBody });
await catalogueStarted;
await expect(page.getByRole("button", { name: "Abmelden" })).toBeVisible();
expect(
await name.evaluate(
(element, original) => element === original,
focusedInput,
),
).toBe(true);
await page
.getByRole("textbox", { name: "Name der Konfiguration", exact: true })
.fill("Ohne GitHub gespeichert");
await expect(name).toHaveValue("Ohne GitHub gespeichert");
await page
.getByRole("button", { name: "Konfigurationen", exact: true })
.click();
Expand All @@ -2149,6 +2190,24 @@ test("database configurations save, reopen, protect revisions and delete without
exact: true,
}),
).toBeVisible();
initialListsReleased = true;
const pending = staleLists.filter((route) => !route.request().failure());
expect(pending.length).toBeGreaterThan(0);
await Promise.all(
pending.map((route) => route.fulfill({ json: { configurations: [] } })),
);
await page.evaluate(
() =>
new Promise<void>((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
),
);
await expect(
storage.getByRole("button", {
name: "Konfiguration öffnen: Ohne GitHub gespeichert",
exact: true,
}),
).toBeVisible();
await expect(
storage.getByRole("button", {
name: "Konfiguration speichern",
Expand Down
10 changes: 10 additions & 0 deletions landing-zone-configurator/docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,16 @@ Use code, current contracts, tests and issue discussions for implementation stat
Historical workstation handoffs and run-specific acceptance logs are not part of
the maintained documentation.

Configuration-list reads must not overwrite a newer post-save refresh with an
older initial response or error. Keep request ordering and the captured
session-lifetime abort signal intact. The database-configuration browser flow
delays initial reads until after a successful save to exercise this ordering on
desktop and mobile, alongside reopen, revision-conflict and delete checks.
The initial guest-to-authenticated session lookup preserves the editor DOM so
it cannot detach a focused input. Actual identity/workspace changes still reset
the draft and scoped catalogue provider; never retain private catalogues across
those boundaries.

## Validation versus cloud operations

| Workflow | Trigger and purpose |
Expand Down
Loading