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 __tests__/dashboard/nav-targets-resolve.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ describe("org nav targets resolve", () => {
"collaborations",
"contracts",
"purchase-orders",
"catalog",
"programs",
"billing",
"payouts",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,9 @@ const read = (rel: string) => readFileSync(join(process.cwd(), rel), "utf8");
* keepPreviousData exists for.
*/
const FILTER_DRIVEN_QUERIES = [
"app/dashboard/consultant/[consultantId]/(features)/earnings/page.tsx",
// The filtered query moved into the Summary panel when Analytics folded onto
// this route as a tab (ADR 19); `page.tsx` is now a server wrapper.
"app/dashboard/consultant/[consultantId]/(features)/earnings/EarningsSummaryPanel.tsx",
"app/dashboard/organization/[orgId]/payouts/PayoutsPageClient.tsx",
"app/dashboard/admin/payouts/_sections/EarningsSection.tsx",
"app/dashboard/organization/[orgId]/purchase-orders/page.tsx",
Expand Down Expand Up @@ -72,7 +74,7 @@ describe("filter-driven dashboard queries keep the previous page on screen", ()
// keepPreviousData `data` is populated on a tab switch, so `!data` is false
// and the page keeps its header, stat cards and tabs.
const src = read(
"app/dashboard/consultant/[consultantId]/(features)/earnings/page.tsx",
"app/dashboard/consultant/[consultantId]/(features)/earnings/EarningsSummaryPanel.tsx",
);
expect(src).toContain("isPlaceholderData");
expect(src).toMatch(/isLoading && !data/);
Expand Down
108 changes: 108 additions & 0 deletions __tests__/enterprise/catalog-archive.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
/**
* Retiring a catalog plan must never delete it.
*
* The plan foreign keys cascade the whole way down —
* `WebinarPlan` → `Webinar` → `Appointment` → `Payment`, every hop declared
* `onDelete: Cascade`. So a hard delete of one catalog row would physically
* destroy the sessions booked from it AND their payment records. The plan row
* is also the terms of every sale made against it: past appointments, invoices
* and earnings all resolve their title, price and duration by reading it.
*
* Archiving is therefore the only safe primitive here, and these tests pin the
* three things that keep it safe:
*
* 1. the catalog endpoint has no delete call at all, so there is no path to
* the cascade even by accident;
* 2. public discovery filters archived plans out;
* 3. the archive filter is NOT attached to the two plan models that lack the
* column, which would make their queries throw.
*
* Source-level assertions, matching the sibling suites: what matters is which
* where-clause each surface reaches for.
*/

import { readFileSync } from "fs";
import { join } from "path";

const read = (rel: string) => readFileSync(join(process.cwd(), rel), "utf8");

const CATALOG_ROUTE = "app/api/organizations/[orgId]/catalog/route.ts";
const PLAN_FILTERS = "app/api/plans/shared/plan-filters.ts";
const VISIBILITY = "lib/api/plans/visibility.ts";
const EXPLORE = "lib/data/explore-programs.ts";
const SCHEMA = "prisma/schema.prisma";

describe("catalog plans are archived, never deleted", () => {
it("the catalog endpoint contains no delete call", () => {
const src = read(CATALOG_ROUTE);
// Not `.not.toContain("delete")` — that would match `DELETE` the HTTP verb.
// These are the Prisma client calls that would actually fire the cascade.
expect(src).not.toMatch(/\.deleteMany\(/);
expect(src).not.toMatch(/\btx\.\w+Plan\.delete\(/);
expect(src).not.toMatch(/\bprisma\.\w+Plan\.delete\(/);
// And it does archive.
expect(src).toMatch(/archivedAt/);
});

it("both archivable models declare the column", () => {
const schema = read(SCHEMA);
for (const model of ["WebinarPlan", "ClassPlan"]) {
const block = schema.slice(
schema.indexOf(`model ${model} {`),
schema.indexOf("\n}", schema.indexOf(`model ${model} {`)),
);
expect(block).toMatch(/archivedAt\s+DateTime\?/);
}
});

it("the cascade this protects against is still declared", () => {
// If someone relaxes these to SetNull/Restrict the archive-only rule could
// be revisited — so pin the premise, not just the conclusion.
const schema = read(SCHEMA);
const webinar = schema.slice(
schema.indexOf("model Webinar {"),
schema.indexOf("\n}", schema.indexOf("model Webinar {")),
);
expect(webinar).toMatch(/webinarPlan\s+WebinarPlan @relation\(.*onDelete: Cascade/);
});
});

describe("archived plans leave public discovery", () => {
it("the shared plan filter pins archivedAt: null", () => {
const src = read(PLAN_FILTERS);
expect(src).toMatch(/archivedAt:\s*null/);
});

it("the event-plan discovery helper carries both gates", () => {
const src = read(VISIBILITY);
const start = src.indexOf("export function eventPlanDiscoverableWhere()");
expect(start).toBeGreaterThan(-1);
// Slice to the function's own closing brace (column 0), not the first "}"
// encountered — that one belongs to the nested visibility object.
const body = src.slice(start, src.indexOf("\n}", start));
expect(body).toMatch(/MARKETPLACE_VISIBILITY/);
expect(body).toMatch(/archivedAt:\s*null/);
});

it("every explore query for an event plan uses that helper", () => {
const src = read(EXPLORE);
// The plain visibility helper would let an archived plan through here; only
// ConsultationPlan / SubscriptionPlan surfaces may still use it, and this
// file queries neither.
expect(src).not.toContain("marketplaceVisibilityWhere(");
expect(src).toContain("eventPlanDiscoverableWhere(");
});

it("the models without the column keep the plain visibility filter", () => {
// ConsultationPlan and SubscriptionPlan have no archivedAt; attaching the
// filter to their queries would make Prisma reject them at runtime.
for (const rel of [
"app/api/plans/consultations/route.ts",
"app/api/plans/subscriptions/route.ts",
]) {
const src = read(rel);
expect(src).toContain("marketplaceVisibilityWhere(");
expect(src).not.toContain("eventPlanDiscoverableWhere(");
}
});
});
136 changes: 136 additions & 0 deletions __tests__/enterprise/catalog-discovery-guard.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
/**
* @jest-environment node
*/

/**
* The marketplace must not surface a tenant-private or a withdrawn plan.
*
* Two guards, one test file, because they fail the same way and are enforced in
* the same `where` clauses:
*
* #726 — an `ORG_ONLY` plan is visible to its own org only, and
* `/explore/**` plus the public plan APIs must filter it out.
* #catalog-archive — an archived plan is withdrawn from sale and must leave
* discovery too.
*
* `#726` shipped with no test at all, which mattered less while nothing could
* actually produce an `ORG_ONLY` row. The org catalog is the first surface that
* can, so the guard is now load-bearing and gets pinned here.
*
* These are BEHAVIOURAL rather than source-level: they call the real functions
* and capture the `where` object actually handed to Prisma. A source grep would
* pass if the filter were built and then dropped before the query.
*
* Deliberately NOT an end-to-end check against a live database. Proving "an
* ORG_ONLY plan stays off the marketplace" that way means creating one on the
* shared database first — and if the guard is broken, which is the case the
* test exists to catch, the plan is briefly live on the public marketplace.
* The failure mode of the experiment is the incident.
*/

import {
buildPlanWhereClause,
type PlanFilterParams,
} from "@/app/api/plans/shared/plan-filters";
import {
marketplaceVisibilityWhere,
eventPlanDiscoverableWhere,
} from "@/lib/api/plans/visibility";

const PUBLIC_SET = ["PUBLIC", "ORG_AND_PUBLIC"];

describe("#726 — ORG_ONLY never reaches a public plan query", () => {
/** Every filter off — what an unfiltered marketplace request produces. */
const NO_FILTERS: PlanFilterParams = {
consultantId: null,
topicIds: null,
language: null,
domainId: null,
sort: null,
minPrice: undefined,
maxPrice: undefined,
search: null,
level: null,
page: 1,
limit: 20,
skip: 0,
};

it("buildPlanWhereClause constrains visibility on an empty filter set", () => {
// This is the real builder behind GET /api/plans/webinars and /classes.
const where = buildPlanWhereClause(NO_FILTERS);
expect(where.visibility).toEqual({ in: PUBLIC_SET });
expect(where.visibility?.in).not.toContain("ORG_ONLY");
});

it("caller-supplied filters cannot dislodge the visibility gate", () => {
// The filters come from query params, so the guard has to survive whatever
// a caller passes. If the builder ever spread user input over its own
// defaults, this is where it would show.
const where = buildPlanWhereClause({
...NO_FILTERS,
consultantId: "consultant-1",
language: "English",
level: "Beginner",
minPrice: 0,
maxPrice: 100000,
search: "anything",
topicIds: "t1,t2",
domainId: "d1",
});

expect(where.visibility).toEqual({ in: PUBLIC_SET });
expect(where.consultantProfileId).toBe("consultant-1");
});

it("the shared visibility constant excludes ORG_ONLY", () => {
expect(marketplaceVisibilityWhere().visibility.in).toEqual(PUBLIC_SET);
expect(eventPlanDiscoverableWhere().visibility.in).toEqual(PUBLIC_SET);
});
});

describe("#catalog-archive — withdrawn plans leave discovery", () => {
it("buildPlanWhereClause excludes archived rows", () => {
expect(
buildPlanWhereClause({
consultantId: null,
topicIds: null,
language: null,
domainId: null,
sort: null,
minPrice: undefined,
maxPrice: undefined,
search: null,
level: null,
page: 1,
limit: 20,
skip: 0,
}).archivedAt,
).toBeNull();
});

it("the event-plan helper carries BOTH gates", () => {
const where = eventPlanDiscoverableWhere();
expect(where.visibility.in).toEqual(PUBLIC_SET);
expect(where.archivedAt).toBeNull();
});

it("the plain helper does NOT carry the archive gate", () => {
// ConsultationPlan and SubscriptionPlan have no archivedAt column. If this
// helper grew the filter, Prisma would reject every query that spreads it —
// which is why the two helpers exist separately rather than one being
// extended.
expect(marketplaceVisibilityWhere()).not.toHaveProperty("archivedAt");
});
});

describe("the two guards are independent", () => {
it("an archived PUBLIC plan is still excluded", () => {
// Regression shape: someone could reasonably assume the visibility gate
// subsumes the archive one. It does not — a plan can be perfectly public
// and still withdrawn.
const where = eventPlanDiscoverableWhere();
expect(where.visibility.in).toContain("PUBLIC");
expect(where.archivedAt).toBeNull();
});
});
Loading
Loading