Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
c1390e2
Keep a refused provider save on screen, and its discovered prices
mlsmaycon Aug 24, 2026
5a95ee2
Merge remote-tracking branch 'origin/main' into agent-network/provide…
mlsmaycon Aug 24, 2026
718b81c
Save the provider before asking the vendor for its models
mlsmaycon Aug 24, 2026
120e236
Keep a refused provider save on the form, and make it look refused
mlsmaycon Aug 24, 2026
15c1ab5
Report a refused provider save once, through the shared toast
mlsmaycon Aug 25, 2026
380c246
Cover what a refused provider save shows the operator
mlsmaycon Aug 25, 2026
360f340
Drive the refused-save spec through the tab that holds the submit
mlsmaycon Aug 25, 2026
0385261
Match the refusal text without pinning its capitalisation
mlsmaycon Aug 25, 2026
39249fd
Revert to sending the credential when loading models for a new provider
mlsmaycon Aug 26, 2026
b61513d
Show the provider save is in flight while the vendor is checked
mlsmaycon Aug 26, 2026
a44cb84
Load models against a retyped URL without asking for the key back
mlsmaycon Aug 26, 2026
f0c1810
Do not report a stale provider list as a failed save
mlsmaycon Aug 26, 2026
5669f0c
Select the refused-save spec by test id, and wait for the refusal
mlsmaycon Aug 26, 2026
8d9356e
Assert the toast is not the success tile, rather than which failure i…
mlsmaycon Aug 26, 2026
a3a351f
Merge remote-tracking branch 'origin/main' into agent-network/provide…
mlsmaycon Sep 2, 2026
87a0090
Report a provider with no listing endpoint as unsupported, not unreac…
mlsmaycon Sep 2, 2026
b31047e
Type the discovery failure with the shared ErrorResponse
mlsmaycon Sep 2, 2026
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
195 changes: 195 additions & 0 deletions e2e/tests/agent-network-provider-save-refused.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
/**
* Agent Network provider save: what the operator sees when the backend refuses.
*
* Saving a provider checks its upstream URL and credential against the vendor
* before storing anything, so a 422 naming the field to correct is an ordinary
* outcome of the form rather than a server fault. Four things have to hold,
* and each one has been wrong at some point:
*
* - while the check runs the submit says so and refuses a second click. The
* vendor call is the slow part and an unreachable upstream drags it out to
* a timeout, which looked exactly like a button that had not registered
* the first click.
* - exactly ONE toast, the shared "Request failed with status code N", which
* quotes the API's own sentence. A second toast from the save path said the
* same thing under a vaguer title.
* - the toast is styled as a failure. notify() renders green with a check
* mark unless told otherwise, so a refusal announced itself as a success.
* - the modal stays open with the typed values intact. The API never returns
* an API key, so closing the form loses it with nowhere to retype it.
*
* The refusal is mocked rather than provoked: the backend check ships with a
* management build these tests do not pin, and what is under test here is the
* dashboard's handling of the response, not the vendor call that produces it.
*
* Like the other agent-network specs, this one builds its own context rather
* than taking the shared dashboardAsOwner fixture: the Agent Network menu is
* deployment-gated behind a localStorage override that has to be in place
* before the first navigation, which addInitScript on an own context is the
* way to do. The route interception below is a second reason — the shared page
* is worker-scoped, and a 422 left on it would follow every later test.
*/
import { type Browser, expect, type Page, test } from "@playwright/test";
import { loginToApp, navigateTo } from "../helpers/auth";
import { generateRandomName } from "../helpers/utils";

const AGENT_NETWORK_CONFIG_KEY = "netbird-test-agent-network";
const PROVIDERS_ENDPOINT = /\/api\/agent-network\/providers(\?|$)/;
const PROVIDER_PREFIX = "e2e-refused-";
const TITLE_TESTID = "notification-title";

// The message a refused save carries: the backend names which of the two
// fields is at fault, without a status code and without echoing the URL.
const REFUSAL = "the upstream url could not be reached: no such host";

// Matched case-insensitively on purpose. The backend lowercases its messages
// (WriteError does, and the copy is written for it) while the toast uppercases
// the first character before rendering. Asserting either spelling would pin
// the test to that transform rather than to the sentence the operator reads.
const REFUSAL_TEXT = new RegExp(
REFUSAL.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"),
"i",
);

async function newAgentNetworkPage(browser: Browser): Promise<{
page: Page;
close: () => Promise<void>;
}> {
const context = await browser.newContext({
storageState: "e2e/fixtures/auth/owner.json",
});
await context.addInitScript(
([key, value]) => {
try {
window.localStorage.setItem(key as string, value as string);
} catch (e) {}
},
[AGENT_NETWORK_CONFIG_KEY, "enabled"],
);
const page = await context.newPage();
await loginToApp(page, "owner");
return { page, close: () => context.close() };
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

// How long the mocked create takes to answer. The real check is a vendor round
// trip, so a save is never instant; holding the response gives the in-flight
// assertions a window to run in without racing the toast.
const REFUSAL_DELAY_MS = 1500;

// refuseProviderCreate answers the create with the 422 the credential check
// produces. Only POST is intercepted: the page still lists providers, and the
// settings bootstrap that may precede the create is left alone.
async function refuseProviderCreate(page: Page) {
await page.route(PROVIDERS_ENDPOINT, async (route) => {
if (route.request().method() !== "POST") return route.continue();
const origin = route.request().headers()["origin"] || "*";
await new Promise((resolve) => setTimeout(resolve, REFUSAL_DELAY_MS));
await route.fulfill({
status: 422,
headers: {
"content-type": "application/json",
"access-control-allow-origin": origin,
},
body: JSON.stringify({ code: 422, message: REFUSAL }),
});
});
}

test.describe
.serial("Agent Network refused provider save @agent-network", () => {
test("reports the refusal once and keeps the form open", async ({
browser,
}) => {
const { page, close } = await newAgentNetworkPage(browser);
try {
await refuseProviderCreate(page);

// navigateTo rather than goto: it dismisses the setup modal that greets a
// fresh account and clears the scroll lock it leaves behind, either of
// which swallows the clicks below.
await navigateTo(page, "/agent-network/providers");

await page
.getByRole("button", { name: "Connect Provider" })
.first()
.click({ force: true });

const providerName = generateRandomName(PROVIDER_PREFIX);
await page
.getByTestId("agent-network-provider-name")
.fill(providerName);
await page
.getByTestId("agent-network-provider-api-key")
.fill("sk-e2e-refused-key");

// The submit lives on the Models tab — the Provider tab's primary button
// only advances to it.
await page.getByRole("tab", { name: "Models" }).click({ force: true });
const submit = page.getByTestId("agent-network-provider-submit");
await expect(submit).toBeEnabled();

const refusal = page.waitForResponse(
(resp) =>
PROVIDERS_ENDPOINT.test(resp.url()) &&
resp.request().method() === "POST",
{ timeout: 30_000 },
);
await submit.click({ force: true });

// ---- the wait is visible while it lasts ----
// A second create is not idempotent, so the button has to say it is
// working and stop taking clicks rather than sit there looking untouched.
// Asserted before the response is awaited: the mock holds it open for
// exactly this window.
await expect(submit).toContainText("Connecting provider");
await expect(submit).toBeDisabled();

expect((await refusal).status()).toBe(422);

// ---- the toast says what the API said ----
const title = page.getByTestId(TITLE_TESTID).first();
await expect(title).toContainText("Request failed with status code 422");
await expect(
page.locator("[data-toast-notification]").first(),
).toContainText(REFUSAL_TEXT);

// ---- and only that toast ----
// The save path used to add its own on top, so the count is the
// assertion rather than the presence of the right one.
await expect(page.locator("[data-toast-notification]")).toHaveCount(1);
await expect(page.getByText("Failed to connect provider")).toHaveCount(0);

// ---- styled as a failure, not a success ----
// notify() paints the icon tile green with a check unless the caller
// says otherwise, which is how a refusal once looked like a success.
//
// A failure reaches the tile two ways — notify()'s own error state, and
// a caller passing its own colour and icon, which is what the shared
// request-failed toast does — so what has to hold is the negative: this
// is not the default success tile.
const icon = page.getByTestId("notification-icon");
await expect(icon).toBeVisible();
await expect(icon).not.toHaveAttribute("data-variant", "success");

// ---- the form is still there, still holding what was typed ----
// The submit only exists while the modal is open, so its presence is the
// check that nothing closed underneath the toast. It also has to come
// back out of the in-flight state, or the retry the toast asks for is
// impossible.
await expect(submit).toBeVisible();
await expect(submit).toBeEnabled();
await expect(submit).toContainText("Connect Provider");
await page.getByRole("tab", { name: "Provider" }).click({ force: true });
await expect(
page.getByTestId("agent-network-provider-name"),
).toHaveValue(providerName);
// The key matters most: the API never returns one, so a form that lost
// it leaves the operator with nothing to correct.
await expect(
page.getByTestId("agent-network-provider-api-key"),
).toHaveValue("sk-e2e-refused-key");
} finally {
await close();
}
});
});
16 changes: 16 additions & 0 deletions src/components/Notification.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,22 @@ export default function Notification<T>({
>
<div className={"flex items-center gap-4"}>
<div
data-testid={"notification-icon"}
// Whether a toast reads as a success or a failure is carried by
// the tile's colour alone, which a test can only assert as a
// tailwind class. Naming the state says the same thing without
// pinning the palette. "success" is the default green tick
// specifically, so a caller that supplied its own colour is
// "custom" rather than folded in with it.
data-variant={
loading
? "loading"
: error
? "error"
: backgroundColor || icon
? "custom"
: "success"
}
className={classNames(
"h-8 w-8 shadow-sm text-white flex items-center justify-center rounded-md shrink-0",
loading
Expand Down
Loading
Loading