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
100 changes: 98 additions & 2 deletions apps/cockpit/scripts/deploy-smoke.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -312,7 +312,7 @@ describe('redirect deploy smoke contract', () => {
expect(sleep).toHaveBeenCalledTimes(1);
});

it('identifies the Vercel Raw Path prerequisite when a raw canary is normalized', async () => {
it('identifies the raw-path rejection route when a raw canary is normalized', async () => {
const cases = buildRedirectSmokeCases('production');
const requestImpl = vi.fn(async (request: RedirectSmokeRequest) => {
const smokeCase = cases.find(
Expand All @@ -329,7 +329,103 @@ describe('redirect deploy smoke contract', () => {
mode: 'production',
requestImpl,
})
).rejects.toThrow(/WAF Raw Path prerequisite/);
).rejects.toThrow(/vercel\.cockpit\.json/);
});

it('accepts only the platform same-origin slash collapse for a consecutive-slash probe', async () => {
// Vercel's CDN collapses consecutive slashes and answers 308 to the
// single-slash path on the same origin before any route, rewrite, or
// function runs, so that probe can never reach the 404 route. The only
// acceptable non-404 answer is that exact normalization; a redirect off
// the deployment from a malformed path is still a contract failure.
const cases = buildRedirectSmokeCases('preview');
const slashCase = cases.find(
(smokeCase) => smokeCase.raw && smokeCase.path.includes('//')
);
const dotCase = cases.find(
(smokeCase) => smokeCase.raw && smokeCase.path.includes('/./')
);
if (!slashCase || !dotCase) throw new Error('Expected raw canaries');
expect(slashCase.platformNormalizedPath).toBe(
'/langgraph/core-capabilities/streaming/overview/python'
);
expect(dotCase.platformNormalizedPath).toBeUndefined();

const impl = (answer: (request: RedirectSmokeRequest) => RedirectSmokeResponse | null) =>
vi.fn(async (request: RedirectSmokeRequest) =>
answer(request) ?? responseFor(request, cases)
);

await expect(
runDeploySmoke({
url: previewUrl,
mode: 'preview',
requestImpl: impl((request) =>
request.path === slashCase.path
? {
status: 308,
// The platform answers with a relative Location.
headers: {
location:
'/langgraph/core-capabilities/streaming/overview/python',
},
}
: null
),
})
).resolves.toBe(`pass:preview:${previewUrl}:${cases.length}`);

await expect(
runDeploySmoke({
url: previewUrl,
mode: 'preview',
requestImpl: impl((request) =>
request.path === slashCase.path
? {
status: 308,
headers: {
location: `${previewUrl}/langgraph/core-capabilities/streaming/overview/python`,
},
}
: null
),
})
).resolves.toBe(`pass:preview:${previewUrl}:${cases.length}`);

await expect(
runDeploySmoke({
url: previewUrl,
mode: 'preview',
requestImpl: impl((request) =>
request.path === slashCase.path
? {
status: 308,
headers: {
location:
'https://threadplane.ai/docs/langgraph/guides/streaming?mode=run',
},
}
: null
),
})
).rejects.toThrow(/raw malformed 1.*expected 404, received 308/);

await expect(
runDeploySmoke({
url: previewUrl,
mode: 'preview',
requestImpl: impl((request) =>
request.path === dotCase.path
? {
status: 308,
headers: {
location: `${previewUrl}/langgraph/core-capabilities/streaming/overview/python`,
},
}
: null
),
})
).rejects.toThrow(/expected 404, received 308/);
});

it('sends the automation bypass on every probe only when a secret is supplied', async () => {
Expand Down
53 changes: 50 additions & 3 deletions apps/cockpit/scripts/deploy-smoke.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,15 @@ export interface RedirectSmokeCase {
readonly expectedLocation?: string;
readonly headers?: Readonly<Record<string, string>>;
readonly raw?: boolean;
/**
* Vercel's CDN collapses consecutive slashes and answers 308 to the
* single-slash path on the same origin before any route, rewrite, or
* function runs, so a raw probe carrying `//` can never reach the 404
* route in vercel.cockpit.json. The only acceptable non-404 answer for such
* a probe is that exact same-origin normalization — never a redirect off
* the deployment.
*/
readonly platformNormalizedPath?: string;
}

export interface DeploySmokeOptions {
Expand Down Expand Up @@ -117,7 +126,15 @@ const notFoundCase = (
name: string,
path: string,
raw = false
): RedirectSmokeCase => ({ name, path, expectedStatus: 404, raw });
): RedirectSmokeCase => ({
name,
path,
expectedStatus: 404,
raw,
...(raw && path.includes('//')
? { platformNormalizedPath: path.replace(/\/{2,}/g, '/') }
: {}),
});

export const RAW_MALFORMED_REQUEST_TARGETS = [
`/${ROOT_STREAMING_LEGACY_PATH}`,
Expand Down Expand Up @@ -384,14 +401,44 @@ export const requestExactTarget: RedirectSmokeRequestImpl = ({

class RedirectContractError extends Error {}

const isPlatformNormalization = (
origin: string,
smokeCase: RedirectSmokeCase,
response: RedirectSmokeResponse
): boolean => {
const location = response.headers.location;
if (
smokeCase.platformNormalizedPath === undefined ||
response.status !== 308 ||
location === undefined
) {
return false;
}
// The platform answers with a relative Location; resolve both sides
// against the deployment origin so only that exact same-origin target
// passes.
let resolved: string;
try {
resolved = new URL(location, `${origin}/`).toString();
} catch {
return false;
}
return (
resolved ===
new URL(smokeCase.platformNormalizedPath, `${origin}/`).toString()
);
};

const verifyCase = (
mode: DeploySmokeMode,
origin: string,
smokeCase: RedirectSmokeCase,
response: RedirectSmokeResponse
): void => {
const rawGateHint = smokeCase.raw
? ' Raw Path rejection failed; verify the Vercel project WAF Raw Path prerequisite before promotion.'
? ' Raw-path rejection failed; verify the 404 route in vercel.cockpit.json still precedes framework routing before promotion.'
: '';
if (isPlatformNormalization(origin, smokeCase, response)) return;
if (response.status !== smokeCase.expectedStatus) {
const protectionHint =
response.status === 302 &&
Expand Down Expand Up @@ -454,7 +501,7 @@ export const runDeploySmoke = async ({
path: smokeCase.path,
...(headers ? { headers } : {}),
});
verifyCase(mode, smokeCase, response);
verifyCase(mode, origin, smokeCase, response);
break;
} catch (error: unknown) {
if (error instanceof RedirectContractError) throw error;
Expand Down
Loading