litellm/tests/e2e/ui/helpers/roundTrip.ts
devin-ai-integration[bot] eb9355d11a
feat(ui): configure Anthropic workload identity federation from the dashboard (#44889)
* feat(ui): configure Anthropic workload identity federation from the dashboard

Add Credential and Edit Credential offer workload identity federation for Anthropic, Add Model creates a federated credential and attaches it, and the credentials table marks federated credentials. Editing a credential now sends only the values the admin changed, and switching provider no longer leaves the previous provider's default base URL on screen

* fix(ui): keep a credential edit to what the admin set in the federation form

* fix(ui): lock the provider in the federation dialog opened from Add Model

* fix(ui): require one federation id when the identity source is the proxy environment

* test(credentials): cover the Anthropic federation dashboard and credential routes

Integration cells for the credential routes every dashboard shape writes (round trips, PATCH set and delete, malformed bodies, non-admin refusals, the token-file allowlist and exchange-host checks, every identity source through chat and messages against a scripted exchange, concurrent writes across two workers and a worker kill mid burst), plus Playwright specs for the Add Credential, Edit Credential and Add Model federation flows and the team-admin view. The owned proxies boot with a 2 s config reload so both workers serve a stored credential inside the fixture budget.

* test(e2e): type the federation spec's captured bodies and clean up the Add Model deployment by its created id

captureRequestBody and postAsMaster take a type parameter instead of returning Record<string, any>, the spec names the credential and model write shapes it captures, and the Add Model cell reads the deployment id from the /model/new response right after the click so a later failing check no longer leaves the deployment behind.

---------

Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
2026-10-07 13:38:58 -07:00

122 lines
3.6 KiB
TypeScript

import { expect, Page } from "@playwright/test";
import { masterKey } from "./traffic";
/**
* Runs `action` and returns the parsed body of the first matching request.
*
* `action` is a callback so the listener is armed before the click; awaiting the
* click first lets the request go by, and the test then hangs until timeout.
*/
export async function captureRequestBody<T = Record<string, any>>(
page: Page,
match: { method: string; urlIncludes: string },
action: () => Promise<void>,
): Promise<T> {
const pending = page.waitForRequest(
(req) =>
req.method() === match.method && req.url().includes(match.urlIncludes),
);
await action();
const request = await pending;
return JSON.parse(request.postData() ?? "{}") as T;
}
/** Reads an endpoint as the master key, so a failure is bad data and not an expired UI token. */
export async function readBack<T = any>(
page: Page,
endpoint: string,
): Promise<T> {
const res = await page.request.get(endpoint, {
headers: { Authorization: `Bearer ${masterKey()}` },
});
expect(res.ok(), `GET ${endpoint}`).toBe(true);
return (await res.json()) as T;
}
type OperationOutcome =
| { readonly status: "success" }
| { readonly status: "failure"; readonly error: unknown };
type RunFailure =
| { readonly status: "action_failure"; readonly error: unknown }
| { readonly status: "cleanup_failure"; readonly error: unknown }
| {
readonly status: "action_and_cleanup_failure";
readonly actionError: unknown;
readonly cleanupError: unknown;
};
function toRunFailure(
actionOutcome: OperationOutcome,
cleanupOutcome: OperationOutcome,
): RunFailure | null {
if (
actionOutcome.status === "failure" &&
cleanupOutcome.status === "failure"
) {
return {
status: "action_and_cleanup_failure",
actionError: actionOutcome.error,
cleanupError: cleanupOutcome.error,
};
}
if (actionOutcome.status === "failure") {
return { status: "action_failure", error: actionOutcome.error };
}
if (cleanupOutcome.status === "failure") {
return { status: "cleanup_failure", error: cleanupOutcome.error };
}
return null;
}
function raiseRunFailure(failure: RunFailure): never {
switch (failure.status) {
case "action_failure":
throw failure.error;
case "cleanup_failure":
throw failure.error;
case "action_and_cleanup_failure":
throw new AggregateError(
[failure.actionError, failure.cleanupError],
"Action and cleanup failed",
);
}
}
async function runAction(
action: () => void | Promise<void>,
): Promise<OperationOutcome> {
return Promise.resolve()
.then(action)
.then(
() => ({ status: "success" as const }),
(error: unknown) => ({ status: "failure" as const, error }),
);
}
async function runCleanup(
cleanup: () => boolean | Promise<boolean>,
): Promise<OperationOutcome> {
return Promise.resolve()
.then(cleanup)
.then(
(succeeded) =>
succeeded
? { status: "success" as const }
: {
status: "failure" as const,
error: new Error("Failed to clean up UI E2E resource"),
},
(error: unknown) => ({ status: "failure" as const, error }),
);
}
export async function runWithCleanup(
action: () => void | Promise<void>,
cleanup: () => boolean | Promise<boolean>,
): Promise<void> {
const actionOutcome = await runAction(action);
const cleanupOutcome = await runCleanup(cleanup);
const failure = toRunFailure(actionOutcome, cleanupOutcome);
if (failure !== null) raiseRunFailure(failure);
}