diff --git a/e2e/tests/agent-network-settings-shapes.spec.ts b/e2e/tests/agent-network-settings-shapes.spec.ts new file mode 100644 index 000000000..c1996dea1 --- /dev/null +++ b/e2e/tests/agent-network-settings-shapes.spec.ts @@ -0,0 +1,144 @@ +/** + * Agent Network settings wire-shape spec. + * + * The management API signals "account not bootstrapped" differently by + * generation: current servers answer GET /agent-network/settings with the + * defaults object carrying an empty cluster/subdomain/endpoint, older ones + * with 200 + a JSON null body, and the oldest with a 404. + * useAgentNetworkSettings normalizes all three to the same null-settings + * signal, and this spec pins that: the providers page must render the + * connect-first endpoint placeholder for every unbootstrapped shape, and the + * endpoint badge once the account is bootstrapped. The settings route is + * mocked per test, so the assertions do not depend on the backend build or + * on account state left behind by other suites. + */ +import { test, expect, type Browser, type Page } from "@playwright/test"; +import { loginToApp } from "../helpers/auth"; + +const AGENT_NETWORK_CONFIG_KEY = "netbird-test-agent-network"; + +const EMPTY_STATE_TEXT = + "Connect your first provider to set up your agent network endpoint."; + +const BOOTSTRAPPED_ENDPOINT = "violet.eu.proxy.netbird.io"; + +// The defaults object current servers return before the account is +// bootstrapped: values present, cluster/subdomain/endpoint empty, no +// timestamps (no row has been persisted yet). +const UNBOOTSTRAPPED_DEFAULTS = { + cluster: "", + subdomain: "", + endpoint: "", + enable_log_collection: true, + enable_prompt_collection: false, + redact_pii: false, + access_log_retention_days: 30, +}; + +const BOOTSTRAPPED_SETTINGS = { + cluster: "eu.proxy.netbird.io", + subdomain: "violet", + endpoint: BOOTSTRAPPED_ENDPOINT, + enable_log_collection: true, + enable_prompt_collection: false, + redact_pii: false, + access_log_retention_days: 30, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", +}; + +async function mockSettingsResponse( + page: Page, + response: { status: number; body: string }, +) { + await page.route("**/api/agent-network/settings", (route) => { + if (route.request().method() !== "GET") return route.continue(); + return route.fulfill({ + status: response.status, + contentType: "application/json", + body: response.body, + }); + }); +} + +async function openProvidersPage( + browser: Browser, + settingsResponse: { status: number; body: string }, +): Promise<{ page: Page; close: () => Promise }> { + const context = await browser.newContext({ + storageState: "e2e/fixtures/auth/owner.json", + }); + // Close the context on any setup failure — the caller only receives the + // close callback once setup succeeds. + try { + 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 mockSettingsResponse(page, settingsResponse); + await loginToApp(page, "owner"); + await page.goto("/agent-network/providers"); + await page.keyboard.press("Escape"); + return { page, close: () => context.close() }; + } catch (e) { + await context.close(); + throw e; + } +} + +const UNBOOTSTRAPPED_SHAPES: { + name: string; + response: { status: number; body: string }; +}[] = [ + { + name: "defaults object with empty endpoint (current servers)", + response: { status: 200, body: JSON.stringify(UNBOOTSTRAPPED_DEFAULTS) }, + }, + { + name: "200 with JSON null body (older servers)", + response: { status: 200, body: "null" }, + }, + { + name: "404 (oldest servers)", + response: { + status: 404, + body: JSON.stringify({ message: "settings not found", code: 404 }), + }, + }, +]; + +test.describe("Agent Network settings wire shapes @agent-network", () => { + for (const shape of UNBOOTSTRAPPED_SHAPES) { + test(`unbootstrapped account renders the empty state: ${shape.name}`, async ({ + browser, + }) => { + const { page, close } = await openProvidersPage(browser, shape.response); + try { + await expect(page.getByText(EMPTY_STATE_TEXT)).toBeVisible(); + await expect(page.getByText(BOOTSTRAPPED_ENDPOINT)).toHaveCount(0); + } finally { + await close(); + } + }); + } + + test("bootstrapped account renders the endpoint instead of the empty state", async ({ + browser, + }) => { + const { page, close } = await openProvidersPage(browser, { + status: 200, + body: JSON.stringify(BOOTSTRAPPED_SETTINGS), + }); + try { + await expect(page.getByText(BOOTSTRAPPED_ENDPOINT)).toBeVisible(); + await expect(page.getByText(EMPTY_STATE_TEXT)).toHaveCount(0); + } finally { + await close(); + } + }); +}); diff --git a/src/modules/agent-network/AIProvidersProvider.tsx b/src/modules/agent-network/AIProvidersProvider.tsx index f04a17f72..2a9170f7f 100644 --- a/src/modules/agent-network/AIProvidersProvider.tsx +++ b/src/modules/agent-network/AIProvidersProvider.tsx @@ -125,8 +125,10 @@ export type APIAgentNetworkSettings = { enable_prompt_collection: boolean; redact_pii: boolean; access_log_retention_days?: number; - created_at: string; - updated_at: string; + // Absent until the account is bootstrapped — pre-bootstrap the backend + // returns the defaults without a persisted row to date. + created_at?: string; + updated_at?: string; }; // APIAgentNetworkSettingsRequest matches the PUT /agent-network/settings @@ -521,10 +523,12 @@ export function useAIProviders() { } // useAgentNetworkSettings fetches the account-level agent-network settings. -// Returns null until the first provider is created — newer backends respond -// 200 + JSON null while no settings row exists; older backends respond 404, -// which we still tolerate via ignoreError so older deploys don't surface -// a spurious error in the empty state. +// Returns null until the account is bootstrapped (first provider create, or +// a settings update carrying a cluster). Backends signal the unbootstrapped +// state differently by age: current ones respond 200 with the defaults and +// an empty cluster/subdomain/endpoint, older ones 200 + JSON null, and the +// oldest 404 — tolerated via ignoreError so old deploys don't surface a +// spurious error in the empty state. All three normalize to null here. export function useAgentNetworkSettings() { const { enabled: agentNetworkEnabled } = useAgentNetworkMode(); const { data, error, isLoading, mutate } = @@ -534,11 +538,14 @@ export function useAgentNetworkSettings() { true, agentNetworkEnabled, ); + const notFound = !!error && (error as { code?: number }).code === 404; + // SWR keeps the previous data alongside the error (keepPreviousData), so a + // later 404 must not expose the stale settings; other transient errors keep + // them, which is what keepPreviousData is for. const settings = useMemo( - () => (data ? settingsFromAPI(data) : null), - [data], + () => (data && data.endpoint && !notFound ? settingsFromAPI(data) : null), + [data, notFound], ); - const notFound = !!error && (error as { code?: number }).code === 404; return { settings, isLoading: isLoading && !notFound, @@ -676,7 +683,7 @@ export default function AIProvidersProvider({ children }: Readonly) { updates.metadataDisabled ?? existing.metadata_disabled, models: updates.models ? toAPIModels(updates.models) - : existing.models ?? [], + : (existing.models ?? []), enabled: updates.enabled ?? existing.enabled, }; try { @@ -761,7 +768,7 @@ export default function AIProvidersProvider({ children }: Readonly) { guardrail_ids: updates.guardrailIds ?? existing.guardrail_ids ?? [], limits: updates.limits ? policyLimitsToAPI(updates.limits) - : existing.limits ?? policyLimitsToAPI(EMPTY_POLICY_LIMITS), + : (existing.limits ?? policyLimitsToAPI(EMPTY_POLICY_LIMITS)), }; try { await policiesApi.put(merged, `/${id}`);