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
1 change: 1 addition & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions packages/settings-ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"@corbits/api-query": "workspace:*",
"@corbits/bench-ui": "workspace:*",
"@corbits/chat-ui": "workspace:*",
"@corbits/error-sink": "workspace:*",
"@corbits/inference-settings": "workspace:*",
"@corbits/react-ui": "github:corbitsdev/react-ui#3b122812a307ccb35be31386f7696020c5a84635",
"@corbits/workflow-catalog": "workspace:*",
Expand Down
35 changes: 30 additions & 5 deletions packages/settings-ui/src/people-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import {
UnauthenticatedError,
describeQueryError,
} from "@corbits/api-query";
import { reportError } from "@corbits/error-sink";
import { PRINCIPAL_KIND_LABEL, principalLabel } from "./identity";
import { AccessPolicyBlock } from "./access-policy";
import {
Expand Down Expand Up @@ -171,7 +172,10 @@ export function PeopleSection({
setInviteOpen(false);
reload();
})
.catch(() => setInviteError(SETTINGS_STRINGS.peopleInviteError))
.catch((cause: unknown) => {
reportError(cause, { operation: "settings.people.invite", tenantId });
setInviteError(SETTINGS_STRINGS.peopleInviteError);
})
.finally(() => setInviting(false));
}

Expand All @@ -180,7 +184,13 @@ export function PeopleSection({
setRowError(null);
deletePendingInvite(tenantId, invite.id)
.then(reload)
.catch(() => setRowError(SETTINGS_STRINGS.pendingInviteCancelError));
.catch((cause: unknown) => {
reportError(cause, {
operation: "settings.people.cancelInvite",
tenantId,
});
setRowError(SETTINGS_STRINGS.pendingInviteCancelError);
});
}

function handleStatusChange(
Expand All @@ -191,15 +201,24 @@ export function PeopleSection({
setRowError(null);
updatePrincipalStatus(tenantId, principal.id, status)
.then(reload)
.catch(() => setRowError(SETTINGS_STRINGS.peopleStatusUpdateError));
.catch((cause: unknown) => {
reportError(cause, {
operation: "settings.people.updateStatus",
tenantId,
});
setRowError(SETTINGS_STRINGS.peopleStatusUpdateError);
});
}

function handleRemove(principal: Principal) {
if (tenantId === null) return;
setRowError(null);
removePrincipal(tenantId, principal.id)
.then(reload)
.catch(() => setRowError(SETTINGS_STRINGS.peopleRemoveError));
.catch((cause: unknown) => {
reportError(cause, { operation: "settings.people.remove", tenantId });
setRowError(SETTINGS_STRINGS.peopleRemoveError);
});
}

function handleRoleChange(
Expand Down Expand Up @@ -232,7 +251,13 @@ export function PeopleSection({
)
.then(() => assignRole(tenantId, principal.id, newRoleId))
.then(reload)
.catch(() => setRowError(SETTINGS_STRINGS.peopleRoleChangeError));
.catch((cause: unknown) => {
reportError(cause, {
operation: "settings.people.changeRole",
tenantId,
});
setRowError(SETTINGS_STRINGS.peopleRoleChangeError);
});
}

return (
Expand Down
219 changes: 217 additions & 2 deletions packages/settings-ui/test/people-section.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,23 @@
// native role-assignment routes, the last owner can't be demoted, and a
// pending invite can be cancelled.

import { afterEach, describe, expect, test } from "bun:test";
import { afterEach, describe, expect, mock, test } from "bun:test";
import { act } from "react";
import { createRoot } from "react-dom/client";
import type { Root } from "react-dom/client";

import { PeopleSection } from "../src/people-section";
const reportErrorCalls: {
error: unknown;
context: Record<string, unknown>;
}[] = [];
mock.module("@corbits/error-sink", () => ({
reportError: (error: unknown, context: Record<string, unknown>) => {
reportErrorCalls.push({ error, context });
return "ref_test";
},
}));

const { PeopleSection } = await import("../src/people-section");

const realFetch = globalThis.fetch;
afterEach(() => {
Expand Down Expand Up @@ -416,4 +427,208 @@ describe("PeopleSection", () => {
container.remove();
}
});

// CL-7139: every mutation catch must report the failure through
// reportError with its own operation, not just set the generic message.
const REPORT_ERROR_CASES: {
readonly name: string;
readonly operation: string;
readonly principals: unknown[];
readonly invites: { readonly data: unknown[] };
readonly failingHandler: Record<string, unknown>;
readonly trigger: (container: HTMLDivElement) => Promise<void>;
}[] = [
{
name: "invite",
operation: "settings.people.invite",
principals: [humanPrincipal()],
invites: noInvites,
failingHandler: {
"POST /api/tenants/tnt_1/access-policy/pending-invites": () =>
json(500, { error: "boom" }),
},
trigger: async (container) => {
const inviteButton = Array.from(
container.querySelectorAll("button"),
).find((b) => b.textContent === "Invite someone");
act(() =>
inviteButton?.dispatchEvent(
new MouseEvent("click", { bubbles: true }),
),
);
await settle();
const emailInput = document.querySelector(
'input[type="email"]',
) as HTMLInputElement;
act(() => setNativeValue(emailInput, "bob@example.com"));
await settle();
const form = document.getElementById(
"invite-person-form",
) as HTMLFormElement;
act(() => {
form.dispatchEvent(
new Event("submit", { bubbles: true, cancelable: true }),
);
});
await settle();
},
},
{
name: "cancelInvite",
operation: "settings.people.cancelInvite",
principals: [humanPrincipal()],
invites: {
data: [
{
id: "pinv_1",
tenantId: "tnt_1",
matchType: "email",
value: "carol@example.com",
roleId: "role_member",
createdAt: timestamps.createdAt,
},
],
},
failingHandler: {
"DELETE /api/tenants/tnt_1/access-policy/pending-invites/pinv_1": () =>
json(500, { error: "boom" }),
},
trigger: async (container) => {
const cancelButton = Array.from(
container.querySelectorAll("button"),
).find((b) => b.textContent === "Cancel");
act(() =>
cancelButton?.dispatchEvent(
new MouseEvent("click", { bubbles: true }),
),
);
await settle();
const confirmButton = Array.from(
container.querySelectorAll("button"),
).find((b) => b.textContent?.includes("Cancel this invite"));
act(() =>
confirmButton?.dispatchEvent(
new MouseEvent("click", { bubbles: true }),
),
);
await settle();
},
},
{
name: "updateStatus",
operation: "settings.people.updateStatus",
principals: [humanPrincipal()],
invites: noInvites,
failingHandler: {
"PATCH /api/tenants/tnt_1/principals/prn_human_1": () =>
json(500, { error: "boom" }),
},
trigger: async (container) => {
const suspendButton = Array.from(
container.querySelectorAll("button"),
).find((b) => b.textContent === "Suspend");
act(() =>
suspendButton?.dispatchEvent(
new MouseEvent("click", { bubbles: true }),
),
);
await settle();
},
},
{
name: "remove",
operation: "settings.people.remove",
principals: [humanPrincipal()],
invites: noInvites,
failingHandler: {
"DELETE /api/tenants/tnt_1/principals/prn_human_1": () =>
json(500, { error: "boom" }),
},
trigger: async (container) => {
const removeButton = Array.from(
container.querySelectorAll("button"),
).find((b) => b.textContent === "Remove");
act(() =>
removeButton?.dispatchEvent(
new MouseEvent("click", { bubbles: true }),
),
);
await settle();
const confirmButton = Array.from(
container.querySelectorAll("button"),
).find((b) => b.textContent === "Remove for good?");
act(() =>
confirmButton?.dispatchEvent(
new MouseEvent("click", { bubbles: true }),
),
);
await settle();
},
},
{
name: "changeRole",
operation: "settings.people.changeRole",
principals: [
humanPrincipal(),
humanPrincipal({
id: "prn_human_2",
displayName: "Bob Baker",
refId: "user_2",
roles: [{ id: MEMBER_ROLE.id, name: MEMBER_ROLE.name }],
}),
],
invites: noInvites,
failingHandler: {
"DELETE /api/tenants/tnt_1/principals/prn_human_2/roles/role_member":
() => json(500, { error: "boom" }),
},
trigger: async (container) => {
const selects = container.querySelectorAll("tbody select");
const bobSelect = Array.from(selects).find(
(s) => (s as HTMLSelectElement).value === "role_member",
) as HTMLSelectElement;
act(() => {
bobSelect.value = "role_owner";
bobSelect.dispatchEvent(new Event("change", { bubbles: true }));
});
await settle();
},
},
];

for (const testCase of REPORT_ERROR_CASES) {
test(`a failing ${testCase.name} reports the error with its operation and tenant`, async () => {
const calls: FetchCall[] = [];
reportErrorCalls.length = 0;
mockFetch(
{
"/api/tenants/tnt_1/principals": {
data: testCase.principals,
nextCursor: null,
},
"/api/tenants/tnt_1/roles": rolesPage,
"/api/tenants/tnt_1/access-policy/pending-invites": testCase.invites,
...testCase.failingHandler,
},
calls,
);

const { container, root } = mount();
try {
await settle();
await testCase.trigger(container);

expect(
reportErrorCalls.some(
(call) =>
call.context.operation === testCase.operation &&
call.context.tenantId === "tnt_1",
),
).toBe(true);
} finally {
act(() => root.unmount());
container.remove();
}
});
}
});
Loading