Convex control plane (Slices 1-4 prerequisites for Slice 5): - Versioned Definition/Design persistence: revisions preserve history instead of deleting prior slices/approvals - Fenced conversation turn queue: attempt-numbered leases prevent stale worker overwrites; expired turns reconciled by cron - Fenced Run/Attempt claiming: only queued attempts from running Runs can be claimed; lease expiry checks on every finish/checkpoint - Multi-slice progression: successful slice marks next slice ready instead of completing the whole Work; completed Runs blocked from retry - Typed AttemptClassification in schema and resolver decisions table - Durable resolver decisions for normal outcomes, cancellation, and lease-expiry recovery - Persistent artifacts and delivery metadata with provenance, source revision, verification status, and controlled delivery transitions - High-impact open questions block definition approval - Question submission gated to defining/awaiting-approval states only - Delivery status updates validate JSON metadata before persisting - Planner failures recorded as durable blocked events - Approval identity uses canonical tokenIdentifier Primitives: - decodeDefinition separates decode from validate - Design validation enforces 1-4 slices with unique IDs and valid deps - Artifact and delivery draft schemas with verified-revision binding - Delivery status transition table Package management: - Consolidated shared deps into single catalog (hono, valibot, streamdown, @types/react, @types/node, @tailwindcss/*, react-native) - Removed cross-version Hono boundary: flue() exported as Fetchable - Deleted bun.lock and regenerated from clean catalog resolution Lint/format: - Removed .eslintignore; oxc uses native ignorePatterns in oxlint.config.ts - Deleted redundant packages/backend/.oxlintrc.json - Added repos/** and scripts/** to ignore patterns - Convex-specific rule overrides for ES2022 target constraints - Fixed all oxc errors in fluePersistence.ts and agents/src/db.ts - Fixed all formatting across changed files
126 lines
4.0 KiB
TypeScript
126 lines
4.0 KiB
TypeScript
import { convexTest } from "convex-test";
|
|
import { anyApi } from "convex/server";
|
|
import { describe, expect, test } from "vitest";
|
|
|
|
import { requireOrganizationMember } from "./authz";
|
|
import schema from "./schema";
|
|
|
|
// `import.meta.glob` is provided by the Vitest/Vite test runner at runtime; it
|
|
// has no type definition under the Convex tsconfig (which scopes `types` to
|
|
// node), so declare the minimal shape the test relies on.
|
|
declare global {
|
|
interface ImportMeta {
|
|
readonly glob: (pattern: string) => Record<string, () => Promise<unknown>>;
|
|
}
|
|
}
|
|
|
|
const modules = import.meta.glob("./**/*.ts");
|
|
const api = anyApi;
|
|
|
|
const ID_A = "https://convex.test|user-a";
|
|
const ID_B = "https://convex.test|user-b";
|
|
|
|
const identityA = { tokenIdentifier: ID_A };
|
|
const identityB = { tokenIdentifier: ID_B };
|
|
|
|
const newTest = () => convexTest({ modules, schema });
|
|
|
|
describe("organizations", () => {
|
|
test("first ensure creates one organization and owner membership", async () => {
|
|
const t = newTest();
|
|
const org = await t
|
|
.withIdentity(identityA)
|
|
.mutation(api.organizations.ensurePersonalOrganization, {});
|
|
|
|
expect(org.kind).toBe("personal");
|
|
expect(org.name).toBe("Personal");
|
|
expect(org.createdBy).toBe(ID_A);
|
|
expect(org._id).toBeTruthy();
|
|
|
|
// Idempotent shape: a second ensure returns the same organization.
|
|
const again = await t
|
|
.withIdentity(identityA)
|
|
.mutation(api.organizations.ensurePersonalOrganization, {});
|
|
expect(again._id).toBe(org._id);
|
|
|
|
// The current-organization query reflects the ensured org.
|
|
const current = await t
|
|
.withIdentity(identityA)
|
|
.query(api.organizations.getCurrent, {});
|
|
expect(current?._id).toBe(org._id);
|
|
});
|
|
|
|
test("repeated ensure returns the same organization without duplicates", async () => {
|
|
const t = newTest();
|
|
|
|
const first = await t
|
|
.withIdentity(identityA)
|
|
.mutation(api.organizations.ensurePersonalOrganization, {});
|
|
|
|
// Many subsequent ensures must all resolve to the single org.
|
|
const repeats = await Promise.all(
|
|
Array.from({ length: 5 }, () =>
|
|
t
|
|
.withIdentity(identityA)
|
|
.mutation(api.organizations.ensurePersonalOrganization, {})
|
|
)
|
|
);
|
|
|
|
for (const org of repeats) {
|
|
expect(org._id).toBe(first._id);
|
|
}
|
|
|
|
// The current-organization query still reports the single org.
|
|
const allForA = await t
|
|
.withIdentity(identityA)
|
|
.query(api.organizations.getCurrent, {});
|
|
expect(allForA?._id).toBe(first._id);
|
|
});
|
|
|
|
test("unauthenticated access is denied", async () => {
|
|
const t = newTest();
|
|
|
|
await expect(t.query(api.organizations.getCurrent, {})).rejects.toThrow(
|
|
/Authentication required/u
|
|
);
|
|
|
|
await expect(
|
|
t.mutation(api.organizations.ensurePersonalOrganization, {})
|
|
).rejects.toThrow(/Authentication required/u);
|
|
});
|
|
|
|
test("a second identity cannot read or mutate the first organization", async () => {
|
|
const t = newTest();
|
|
|
|
const orgA = await t
|
|
.withIdentity(identityA)
|
|
.mutation(api.organizations.ensurePersonalOrganization, {});
|
|
|
|
// The second identity sees none of the first identity's organizations.
|
|
const currentForB = await t
|
|
.withIdentity(identityB)
|
|
.query(api.organizations.getCurrent, {});
|
|
expect(currentForB).toBeNull();
|
|
|
|
// The second identity gets its own distinct organization.
|
|
const orgB = await t
|
|
.withIdentity(identityB)
|
|
.mutation(api.organizations.ensurePersonalOrganization, {});
|
|
expect(orgB._id).not.toBe(orgA._id);
|
|
|
|
// The membership boundary denies the second identity access to org A.
|
|
await expect(
|
|
t
|
|
.withIdentity(identityB)
|
|
.query((ctx) => requireOrganizationMember(ctx, orgA._id))
|
|
).rejects.toThrow(/Organization membership required/u);
|
|
|
|
// But the first identity is a confirmed member of its own organization.
|
|
await expect(
|
|
t
|
|
.withIdentity(identityA)
|
|
.query((ctx) => requireOrganizationMember(ctx, orgA._id))
|
|
).resolves.toBe(ID_A);
|
|
});
|
|
});
|