Skip to content
Open
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
30 changes: 24 additions & 6 deletions src/browser/commands/restoreState/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,30 @@ import type { Browser } from "../../types";
import { DEVTOOLS_PROTOCOL, WEBDRIVER_PROTOCOL } from "../../../constants/config";
import { getOverridesProtocol, getWebdriverFrames, SaveStateData } from "../saveState";
import { getActivePuppeteerPage } from "../../existing-browser";
import { Cookie } from "@testplane/wdio-protocols";
import { StateOpts } from "../../../config/types";
import type { Cookie as WebdriverCookie } from "@testplane/wdio-protocols";
import type { Cookie } from "../../../types";

export type RestoreStateOptions = Omit<StateOpts, "keepFile"> & {
data?: SaveStateData;
refresh?: boolean;
};

const normalizeCookiePrefixConstraints = (cookie: Cookie): Cookie => {
if (cookie.name.startsWith("__Host-")) {
const cookieWithoutDomain = { ...cookie };
delete cookieWithoutDomain.domain;

return { ...cookieWithoutDomain, path: "/", secure: true };
Comment on lines +20 to +23
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve the saved host for __Host cookies

When a state file or data object contains a __Host- cookie for a host other than the currently open page, deleting domain leaves the cookie with neither domain nor url. The DevTools path then lets Puppeteer fill the missing URL from the current page, and WebDriver defaults a missing domain to the current document, so the restore writes the host-only cookie to the wrong host instead of the saved one. This corrupts restored auth state for flows that restore saved cookies after navigating to a different origin; use the saved domain to build a URL or require navigating to that host instead of dropping it completely.

Useful? React with 👍 / 👎.

}

if (cookie.name.startsWith("__Secure-")) {
return { ...cookie, secure: true };
}

return cookie;
};

export default (browser: Browser): void => {
const { publicAPI: session } = browser;

Expand All @@ -40,13 +56,15 @@ export default (browser: Browser): void => {
restoreState.cookies = restoreState?.cookies.filter(options.cookieFilter);
}

const cookies = restoreState.cookies?.map(normalizeCookiePrefixConstraints);

switch (getOverridesProtocol(browser)) {
case WEBDRIVER_PROTOCOL: {
await session.switchToParentFrame();

if (restoreState.cookies && options.cookies) {
if (cookies && options.cookies) {
await session.setCookies(
restoreState.cookies.map(
cookies.map(
cookie =>
({
...cookie,
Expand All @@ -55,7 +73,7 @@ export default (browser: Browser): void => {
cookie.sameSite && session.isBidi
? cookie.sameSite.toLowerCase()
: cookie.sameSite,
} as Cookie),
} as WebdriverCookie),
),
);
}
Expand Down Expand Up @@ -114,8 +132,8 @@ export default (browser: Browser): void => {

const frames = page.frames();

if (restoreState.cookies && options.cookies) {
await page.setCookie(...restoreState.cookies);
if (cookies && options.cookies) {
await page.setCookie(...cookies);
}

for (const frame of frames) {
Expand Down
221 changes: 221 additions & 0 deletions test/src/browser/commands/restoreState.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
"use strict";

const restoreStateCommand = require("src/browser/commands/restoreState").default;
const { DEVTOOLS_PROTOCOL, WEBDRIVER_PROTOCOL } = require("src/constants/config");
const { mkSessionStub_ } = require("../utils");

describe('"restoreState" command', () => {
const sandbox = sinon.createSandbox();

const mkBrowser_ = ({ session, automationProtocol = WEBDRIVER_PROTOCOL } = {}) => ({
publicAPI: session,
config: {
automationProtocol,
isolation: false,
stateOpts: {
cookies: true,
localStorage: true,
sessionStorage: true,
},
},
});

const initWebdriverSession_ = ({ isBidi = false } = {}) => {
const session = mkSessionStub_();
session.getUrl.resolves("https://example.com/page");
session.isBidi = isBidi;
session.setCookies = sandbox.stub().named("setCookies").resolves();
session.refresh = sandbox.stub().named("refresh").resolves();

restoreStateCommand(mkBrowser_({ session }));

return session;
};

const initDevtoolsSession_ = () => {
const session = mkSessionStub_();
const page = {
setCookie: sandbox.stub().named("setCookie").resolves(),
frames: sandbox.stub().named("frames").returns([]),
reload: sandbox.stub().named("reload").resolves(),
target: sandbox.stub().named("target").returns({ _targetId: "active-target" }),
};

session.getUrl.resolves("https://example.com/page");
session.getWindowHandle = sandbox.stub().named("getWindowHandle").resolves("active-target");
session.getPuppeteer.resolves({
pages: sandbox.stub().named("pages").resolves([page]),
});

restoreStateCommand(mkBrowser_({ session, automationProtocol: DEVTOOLS_PROTOCOL }));

return { page, session };
};

afterEach(() => sandbox.restore());

it("should normalize cookie prefix constraints before webdriver restore", async () => {
const session = initWebdriverSession_();
const hostCookie = {
name: "__Host-csrf-token",
value: "host-value",
domain: "www.chromatic.com",
path: "/custom",
secure: false,
httpOnly: true,
sameSite: "Lax",
expires: 12345,
};
const secureCookie = {
name: "__Secure-session",
value: "secure-value",
domain: "www.chromatic.com",
path: "/auth",
secure: false,
sameSite: "Strict",
};
const ordinaryCookie = {
name: "regular",
value: "regular-value",
domain: "www.chromatic.com",
path: "/custom",
secure: false,
httpOnly: true,
sameSite: "Lax",
};

await session.restoreState({
data: { cookies: [hostCookie, secureCookie, ordinaryCookie] },
cookies: true,
refresh: false,
});

assert.calledOnce(session.setCookies);
assert.deepEqual(session.setCookies.firstCall.args[0], [
{
name: "__Host-csrf-token",
value: "host-value",
path: "/",
secure: true,
httpOnly: true,
sameSite: "Lax",
expires: 12345,
},
{
name: "__Secure-session",
value: "secure-value",
domain: "www.chromatic.com",
path: "/auth",
secure: true,
sameSite: "Strict",
},
ordinaryCookie,
]);
assert.property(hostCookie, "domain");
});

it("should keep webdriver sameSite/BiDi normalization", async () => {
const session = initWebdriverSession_({ isBidi: true });

await session.restoreState({
data: {
cookies: [
{
name: "same-site-none",
value: "value",
secure: false,
sameSite: "None",
},
],
},
cookies: true,
refresh: false,
});

assert.calledOnceWith(session.setCookies, [
{
name: "same-site-none",
value: "value",
secure: true,
sameSite: "none",
},
]);
});

it("should normalize cookie prefix constraints before devtools restore", async () => {
const { page, session } = initDevtoolsSession_();
const ordinaryCookie = {
name: "regular",
value: "regular-value",
domain: "www.chromatic.com",
path: "/custom",
secure: false,
sameSite: "Lax",
};

await session.restoreState({
data: {
cookies: [
{
name: "__Host-csrf-token",
value: "host-value",
domain: "www.chromatic.com",
path: "/custom",
secure: false,
},
{
name: "__Secure-session",
value: "secure-value",
domain: "www.chromatic.com",
path: "/auth",
secure: false,
},
ordinaryCookie,
],
},
cookies: true,
refresh: false,
});

assert.calledOnce(page.setCookie);
assert.deepEqual(page.setCookie.firstCall.args, [
{
name: "__Host-csrf-token",
value: "host-value",
path: "/",
secure: true,
},
{
name: "__Secure-session",
value: "secure-value",
domain: "www.chromatic.com",
path: "/auth",
secure: true,
},
ordinaryCookie,
]);
});

it("should not ignore unrelated cookie restore errors", async () => {
const session = initWebdriverSession_();
const error = new Error("unable to set cookie");

session.setCookies.rejects(error);

await assert.isRejected(
session.restoreState({
data: {
cookies: [
{
name: "invalid-cookie",
value: "value",
},
],
},
cookies: true,
refresh: false,
}),
/unable to set cookie/,
);
});
});