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
40 changes: 40 additions & 0 deletions apps/backstage/src/components/initiative-form.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,4 +83,44 @@ describe("InitiativeForm", () => {
expect(screen.queryByLabelText("Estado")).not.toBeInTheDocument();
expect(screen.getByText(/no se puede reabrir/i)).toBeInTheDocument();
});

// `featured` curation is gated (rules' canCurateFeatured): Admin by role, everyone else by
// the update:Showcase perm. A non-curator must not even see the control — the write would
// be denied by firestore.rules, taking the whole save down with it.
it("renders the destacar checkbox only when the caller may curate", () => {
const { unmount } = render(
<InitiativeForm
memberOptions={[]}
submitLabel="Guardar"
isSaving={false}
onSubmit={vi.fn()}
canFeature
/>,
);
expect(screen.getByLabelText(/destacar en \/programas/i)).toBeInTheDocument();
unmount();

render(
<InitiativeForm
memberOptions={[]}
submitLabel="Guardar"
isSaving={false}
onSubmit={vi.fn()}
canFeature={false}
/>,
);
expect(screen.queryByLabelText(/destacar en \/programas/i)).not.toBeInTheDocument();
});

it("hides the destacar checkbox when canFeature is not passed at all", () => {
render(
<InitiativeForm
memberOptions={[]}
submitLabel="Guardar"
isSaving={false}
onSubmit={vi.fn()}
/>,
);
expect(screen.queryByLabelText(/destacar en \/programas/i)).not.toBeInTheDocument();
});
});
14 changes: 9 additions & 5 deletions apps/backstage/src/components/initiative-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,11 @@ interface InitiativeFormProps {
isSaving: boolean;
onSubmit: (data: InitiativeInput) => void;
lockStatus?: boolean;
/** Whether the caller may set `featured` — Admin/ProjectManager only, mirroring
* the rules' `featuredUpdateSafe`. A direction/perm editor sees it disabled. */
/** Whether the caller may set `featured` — the Admin ROLE, or the `update:Showcase`
* PERM, mirroring the rules' `canCurateFeatured()`. Not a ProjectManager role check:
* the seed grants that role the perm, so deactivating the role now revokes curation,
* and a custom role carrying `update:Showcase` gains it. A direction/perm editor
* without either sees it disabled. */
canFeature?: boolean;
}

Expand Down Expand Up @@ -207,9 +210,10 @@ export function InitiativeForm({
</Select>
</Field>
)}
{/* `featured` curation is Admin/ProjectManager-only (rules' featuredUpdateSafe);
a non-curator can never set it here, so hide the control entirely. The form
still submits the initiative's current value (unchanged), which the rule allows. */}
{/* `featured` curation needs the Admin role or the `update:Showcase` perm (rules'
canCurateFeatured); a non-curator can never set it here, so hide the control
entirely. The form still submits the initiative's current value (unchanged),
which the rule allows. */}
{canFeature && (
<div className="flex flex-col gap-1">
<Controller
Expand Down
25 changes: 25 additions & 0 deletions apps/backstage/src/components/nav-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,31 @@ describe("isNavItemVisible — conditional grants must not leak", () => {
expect(canSee("/positions", claimsFor("Treasury"))).toBe(false);
});

it("admits an update:Position custom role to /positions, matching the catalog's own rule", () => {
// The catalog arms are canDo('update','Position') / canDo('create','Position'), and
// canDo treats manage:Position as satisfying update:Position — so keying `orCan` on
// `update` widens nothing the rules did not already allow, and stops the nav from
// hiding a page whose writes this principal can actually make (guardrail #6).
const orgChartEditor: AuthClaims = { roles: [], perms: ["update:Position"] };
expect(canSee("/positions", orgChartEditor)).toBe(true);
// read:Position is what a plain Member carries; it must still not open the page.
expect(canSee("/positions", { roles: [], perms: ["read:Position"] })).toBe(false);
});

it("gates /members on read:Member alone — update:Position opens nothing here", () => {
// Cargo assignment happens ON /members (the member roster), and the nav probes
// read:Member there. So the perm that carries the members-positions LANE is inert for
// reaching the page: a custom role holding only update:Position cannot get to the
// capability the owner-op hands it, which is why owner-op 1 mandates BOTH perms.
// Both halves are load-bearing and falsifiable: read:Member alone is what opens the
// page (drop it from the nav gate and the first line goes red), update:Position alone
// is what does not (add it as an `orCan` and the second goes red). The pair assertion
// this replaced was neither — read:Member already satisfied it, so deleting
// update:Position from it could not turn anything red.
expect(canSee("/members", { roles: [], perms: ["read:Member"] })).toBe(true);
expect(canSee("/members", { roles: [], perms: ["update:Position"] })).toBe(false);
});

it("shows /notificaciones to a compose-only principal (create:Notification, no read)", () => {
// The page's history list gates on read:Notification, but a compose-only principal
// holds only create:Notification. The item's `subject: Notification` read would hide
Expand Down
9 changes: 6 additions & 3 deletions apps/backstage/src/components/nav-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,10 +124,13 @@ export const NAV_GROUPS: NavGroup[] = [
// Members can read Position (chip resolution on /me), and Membership shares
// ONLY that same read grant — so no perm cleanly separates catalog viewers
// from Members; hence the built-in allowlist. `orCan` re-admits a dynamic
// custom role that manages the org chart (manage:Position) but carries no
// built-in role name, so the route guard doesn't lock the perms system out.
// custom role that edits the org chart but carries no built-in role name, so
// the route guard doesn't lock the perms system out. Keyed on `update` to match
// the catalog's own rules (canDo('update','Position')), which canDo already
// treats manage:Position as satisfying — so this admits no principal the rules
// did not already let write.
roles: ["Admin", "Membership", "ExecutiveCommittee"],
orCan: { action: "manage", subject: "Position" },
orCan: { action: "update", subject: "Position" },
},
{ to: "/permisos", label: "Permisos", icon: "lock", roles: ["Admin"] },
],
Expand Down
157 changes: 154 additions & 3 deletions apps/backstage/src/features/members/components/member-form.test.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { Position } from "@luminova/types";
import type { MemberInput, Position } from "@luminova/types";
import { MemberForm } from "./member-form";
import { toMemberUpdateDoc } from "../repositories/member-mapper";
import { pickDate } from "../../../test/pick-date";

const positions: Position[] = [
Expand Down Expand Up @@ -91,8 +92,13 @@ describe("MemberForm", () => {
expect(onSubmit).not.toHaveBeenCalled();
});

// allowPowerGrants: pos-pres is a grant-free CEL cargo, which only an Admin may assign
// (rules' cargoAssignableByNonAdmin). This case is about the LABELS, so give it the
// authority that renders them all.
it("shows gendered cargo labels and excludes comisiones from the cargo options", async () => {
render(<MemberForm positions={positions} submitLabel="Crear" onSubmit={vi.fn()} />);
render(
<MemberForm positions={positions} submitLabel="Crear" onSubmit={vi.fn()} allowPowerGrants />,
);
await userEvent.click(screen.getByRole("button", { name: "Femenino" }));
await userEvent.click(screen.getByLabelText("Cargo"));
expect(await screen.findByText("Presidenta")).toBeInTheDocument();
Expand Down Expand Up @@ -183,9 +189,12 @@ describe("MemberForm", () => {
);
});

// allowPowerGrants for the same reason: picking a CEL cargo at all is an Admin flow.
it("locks comisiones as Comité Ejecutivo Local and clears them for a CEL cargo", async () => {
const onSubmit = vi.fn().mockResolvedValue(undefined);
render(<MemberForm positions={positions} submitLabel="Crear" onSubmit={onSubmit} />);
render(
<MemberForm positions={positions} submitLabel="Crear" onSubmit={onSubmit} allowPowerGrants />,
);
await userEvent.type(screen.getByLabelText(/nombre/i), "Ana Pérez");
await userEvent.type(screen.getByLabelText(/correo/i), "ana@jci.bo");
await userEvent.click(screen.getByRole("button", { name: "Femenino" }));
Expand Down Expand Up @@ -229,6 +238,148 @@ describe("MemberForm", () => {
expect(await screen.findByText("Tesorero (inactivo)")).toBeInTheDocument();
});

// Mirror of firestore.rules cargoAssignableByNonAdmin() on the CREATE lane
// (createPositionsSafe applies the same predicate). Without it a non-Admin sees a
// grant-free CEL cargo, picks 'Presidente', and the create 403s into a generic error.
it("hides a grant-free CEL cargo from a non-Admin and keeps the JDL dirección", async () => {
render(<MemberForm positions={positions} submitLabel="Crear" onSubmit={vi.fn()} />);
await userEvent.click(screen.getByLabelText("Cargo"));
expect(await screen.findByText("Director de Área")).toBeInTheDocument();
expect(screen.queryByText("Presidente")).not.toBeInTheDocument();
});

it("shows a grant-free CEL cargo to an Admin", async () => {
render(
<MemberForm positions={positions} submitLabel="Crear" onSubmit={vi.fn()} allowPowerGrants />,
);
await userEvent.click(screen.getByLabelText("Cargo"));
expect(await screen.findByText("Presidente")).toBeInTheDocument();
});

// The lock, not just the option list: every save re-stamps the assigned cargoId, so a
// non-Admin editing a member already seated on a grant-free CEL cargo is denied on the
// positions slot. Locking it keeps the bio fields savable (the mapper omits the
// unchanged slot) instead of failing the whole form with no explanation.
// BLOCKING: the rules conjuncts are asymmetric. Keeping a grant-free CEL seat is denied
// (`cargoAssignableByNonAdmin`), but CLEARING it is allowed on purpose —
// `currentCargoGrantsEmpty()` is not category-gated, because denying it "would strand a
// takedown behind an Admin". So the seat renders disabled rather than locked or dropped.
const celSeated = {
name: "Ana Pérez",
email: "ana@jci.bo",
gender: "Femenino" as const,
joinDate: "2020-03-15",
birthdate: "1992-07-15",
status: "Activo" as const,
cargoId: "pos-pres",
comisionIds: [],
};

it("BLOCKING: does NOT lock a grant-free CEL seat — clearing it is the allowed takedown", () => {
render(
<MemberForm
positions={positions}
defaultValues={{ cargoId: "pos-pres", gender: "Masculino" }}
submitLabel="Guardar"
onSubmit={vi.fn()}
/>,
);
expect(screen.queryByText(/Solo un Admin puede cambiar el cargo/i)).not.toBeInTheDocument();
});

// Dropping the seat from the options handed it to the `(inactivo)` fallback, which re-added
// an ACTIVE cargo under an inactive label — and re-offered it to the very non-Admin whose
// write the rules reject. The two member forms must answer the same rules predicate.
it("BLOCKING: never labels the active grant-free CEL seat '(inactivo)' to a non-Admin", () => {
render(
<MemberForm
positions={positions}
defaultValues={{ cargoId: "pos-pres", gender: "Masculino" }}
submitLabel="Guardar"
onSubmit={vi.fn()}
/>,
);
const trigger = screen.getByLabelText("Cargo");
expect(trigger).toHaveTextContent("Presidente");
expect(trigger).not.toHaveTextContent(/inactivo/i);
});

it("BLOCKING: reaches the takedown of a grant-free CEL seat and submits cargoId null", async () => {
const onSubmit = vi.fn().mockResolvedValue(undefined);
render(
<MemberForm
positions={positions}
defaultValues={celSeated}
submitLabel="Guardar"
onSubmit={onSubmit}
/>,
);
await userEvent.click(screen.getByRole("button", { name: /quitar cargo/i }));
expect(screen.getByLabelText("Cargo")).toHaveTextContent("Sin cargo");
await userEvent.click(screen.getByRole("button", { name: /guardar/i }));
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ cargoId: null }));
});

it("BLOCKING: a non-Admin cannot re-assign the grant-free CEL seat once cleared", async () => {
const onSubmit = vi.fn().mockResolvedValue(undefined);
render(
<MemberForm
positions={positions}
defaultValues={celSeated}
submitLabel="Guardar"
onSubmit={onSubmit}
/>,
);
await userEvent.click(screen.getByRole("button", { name: /quitar cargo/i }));
await userEvent.click(screen.getByLabelText("Cargo"));
const seat = await screen.findByRole("option", { name: "Presidenta" });
expect(seat).toHaveAttribute("aria-disabled", "true");
await userEvent.click(seat);
await userEvent.keyboard("{Escape}");
expect(screen.getByLabelText("Cargo")).toHaveTextContent("Sin cargo");
await userEvent.click(screen.getByRole("button", { name: /guardar/i }));
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ cargoId: null }));
});

// The other half of "never submittable": leaving the seat untouched is the one state that
// still carries the CEL cargoId out of the form, and it must never become a positions
// WRITE. Asserted through the mapper the edit lane actually uses, not by inspection — the
// form's safety here is entirely toMemberUpdateDoc omitting an unchanged slot.
it("BLOCKING: an untouched CEL seat never reaches the positions write", async () => {
const onSubmit = vi.fn().mockResolvedValue(undefined);
render(
<MemberForm
positions={positions}
defaultValues={celSeated}
submitLabel="Guardar"
onSubmit={onSubmit}
/>,
);
await userEvent.click(screen.getByRole("button", { name: /guardar/i }));
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
const [submitted] = onSubmit.mock.calls[0]! as [MemberInput];
expect(submitted.cargoId).toBe("pos-pres");
const doc = toMemberUpdateDoc(submitted, "uid-editor", {
cargoId: "pos-pres",
comisionIds: [],
});
expect(Object.keys(doc).some((key) => key.startsWith("positions."))).toBe(false);
});

it("does NOT lock a non-Admin editing a member on a grant-free JDL dirección", () => {
render(
<MemberForm
positions={positions}
defaultValues={{ cargoId: "pos-jdl", gender: "Masculino" }}
submitLabel="Guardar"
onSubmit={vi.fn()}
/>,
);
expect(screen.queryByText(/Solo un Admin puede cambiar el cargo/i)).not.toBeInTheDocument();
});

it("renders comisión option as 'sigla — title' when sigla is present", async () => {
render(
<MemberForm
Expand Down
Loading
Loading