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
2 changes: 1 addition & 1 deletion apps/booskiff-web/bff/auth-real.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ test("returns OAuth next URL and Kratos cookies without an app session when logi
// Then: OAuth 開始 URL と Kratos cookie のみを返す。
expect(response?.status).toBe(200);
expect(await response?.json()).toEqual({
authenticated: true, username: "alice@example.test", next: "/auth/oauth/start?return_to=/login",
authenticated: true, username: "alice@example.test", next: "/auth/oauth/start?return_to=/drive",
});
expect(response?.headers.getSetCookie().some((cookie) => cookie.startsWith("ory_kratos_session="))).toBe(true);
expect(response?.headers.getSetCookie().some((cookie) => cookie.startsWith("booskiff_session="))).toBe(false);
Expand Down
5 changes: 3 additions & 2 deletions apps/booskiff-web/bff/auth-real.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,8 +112,9 @@ export async function realLogin(req: Request, config: RealAuthConfig, adapter: S
const headers = new Headers({ "Content-Type": "application/json" });
jar.applyToResponse(headers);
// XHR は 302 で cross-origin Hydra chain を進められないため、トップレベル遷移用 URL を渡す。
// return_to=/login は #19 の SPA 経路回避策。修正後に /drive へ戻すことを再検討する。
return new Response(JSON.stringify({ authenticated: true, username: email, next: "/auth/oauth/start?return_to=/login" }), { status: 200, headers });
// return_to=/drive: callback 後の /drive フルロードで発生していた resume DOM 破損 (#19) は
// #21 で修正済みのため、SPA 経路回避策 (return_to=/login) は解除する。
return new Response(JSON.stringify({ authenticated: true, username: email, next: "/auth/oauth/start?return_to=/drive" }), { status: 200, headers });
} catch (err) {
console.error("Kratos login error:", err);
return Response.json({ error: "Authentication service unavailable" }, { status: 502 });
Expand Down
18 changes: 8 additions & 10 deletions apps/booskiff-web/e2e/real/tests/auth.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,8 @@ async function enterCredentials(page: Page): Promise<void> {
}).toPass({ timeout: 20_000 });
// Real-mode login success hands off to /auth/oauth/start automatically (the
// BFF `next` URL), so the Hydra consent screen arrives through the UI flow
// alone. return_to=/login is applied BFF-side: a direct full load of /drive
// after the OAuth callback corrupts the resumed DOM (issue #19), so landing
// on /login and letting the session check navigate client-side follows the
// healthy SPA path.
// alone. return_to=/drive: the OAuth callback lands on a full /drive load,
// whose resumed DOM stays intact since the issue #19 fix (#21).
await expect(page.getByRole("button", { name: "Allow", exact: true })).toBeVisible({ timeout: 30_000 });
}

Expand Down Expand Up @@ -89,12 +87,12 @@ test("real login grants a cookie session, drive access, and logout", async ({ pa
const created = page.waitForResponse((response) => new URL(response.url()).pathname === "/api/folders" && response.request().method() === "POST");
await page.getByTestId("folder-create-submit").click();
expect((await created).ok()).toBe(true);
// Persistence is verified through the API: a full /drive reload re-triggers the
// resumed-DOM corruption noted above, so the browser-side reload assertion is
// intentionally left to the follow-up fix.
const folders = await page.request.get("/api/folders");
expect(folders.ok()).toBe(true);
expect(JSON.stringify(await folders.json())).toContain(folder);
// Persistence is verified through the UI after a full /drive reload: SSR +
// resumeMount must render the stored folder without the DOM corruption that
// issue #19 documented (fixed in #21).
await page.goto("/drive");
await expect(page.getByTestId("drive-page")).toBeVisible();
await expect(page.getByTestId("folder-list")).toContainText(folder);

await page.getByTestId("logout-button").click();
await expect(page).toHaveURL(/\/login$/);
Expand Down
45 changes: 45 additions & 0 deletions apps/booskiff-web/e2e/tests/drive-resume.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { expect, test } from "@playwright/test";
import { folderRow, loginViaUi, runId } from "./helpers";

// Regression for #19 / #21: an authenticated FULL page load of /drive (SSR +
// resumeMount, then CheckSession -> LoadDrive -> *Loaded patches) must not
// corrupt the client DOM. The observed corruption was duplicated folder
// section elements (folder-create-submit / folder-list rendered twice),
// children leaking between sections, and dead event handlers.
test("authenticated /drive full reload keeps a single, working folder section", async ({
page,
}) => {
await loginViaUi(page);

// Full page load: SSR HTML + resumeMount + session/drive data patches.
// Register response waits BEFORE the navigation so no patch is missed.
const filesLoaded = page.waitForResponse(
(response) =>
response.url().includes("/api/files") &&
response.request().method() === "GET",
);
const foldersLoaded = page.waitForResponse(
(response) =>
response.url().includes("/api/folders") &&
response.request().method() === "GET",
);
const billingLoaded = page.waitForResponse(
(response) => response.url().includes("/api/billing/status"),
);
await page.goto("/drive");
await filesLoaded;
await foldersLoaded;
await billingLoaded;
await expect(page.getByTestId("drive-page")).toBeVisible();

// Folder section elements must exist exactly once (no duplication).
await expect(page.getByTestId("folder-create-submit")).toHaveCount(1);
await expect(page.getByTestId("folder-list")).toHaveCount(1);
await expect(page.getByTestId("folder-name-input")).toHaveCount(1);

// Event handlers must survive resume: folder creation still works.
const name = `resume-${runId}`;
await page.getByTestId("folder-name-input").fill(name);
await page.getByTestId("folder-create-submit").click();
await expect(folderRow(page, name)).toBeVisible({ timeout: 15_000 });
});
16 changes: 12 additions & 4 deletions apps/booskiff-web/src/App/View/Drive.purs
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,17 @@ view model =
, fileSection model
]

-- | Invisible stand-in for "render nothing" slots. An empty text node never
-- | survives HTML parsing, but Flame's hydration matches SSR DOM to virtual
-- | children purely by index — a vanished placeholder shifts every following
-- | sibling onto the wrong node and later patches then corrupt the DOM
-- | (duplicated sections, leaked children, dead handlers; #19/#21).
emptySlot :: Html Message
emptySlot = HE.div [ HA.style { display: "none" } ] []

errorBanner :: Maybe String -> Html Message
errorBanner = case _ of
Nothing -> HE.text ""
Nothing -> emptySlot
Just msg ->
HE.div
[ HA.class'
Expand Down Expand Up @@ -96,8 +104,8 @@ uploadSection model =
]
[ HE.text "アップロード" ]
]
, maybe (HE.text "") progressView model.upload
, maybe (HE.text "") uploadErrorView (uploadErrorFor model)
, maybe emptySlot progressView model.upload
, maybe emptySlot uploadErrorView (uploadErrorFor model)
]

uploadErrorFor :: Model -> Maybe String
Expand Down Expand Up @@ -166,7 +174,7 @@ folderSection model =

allButton :: Maybe String -> Html Message
allButton = case _ of
Nothing -> HE.text ""
Nothing -> emptySlot
Just _ ->
HE.button
[ HA.class' ("text-sm " <> T.navLink)
Expand Down
Loading