4efe0a07c4
Members added by SSO just-in-time provisioning or Directory Sync never got their per-member DEVELOPMENT environments - only invite acceptance and project creation created them. `trigger dev` returned "Environment not found" for those members and the dashboard had no dev view. ensureOrgMember now queues provisioning for every membership it settles, so both paths are covered and members missing environments are repaired on their next sync. Provisioning runs as a common-worker job to keep sign-in and directory webhooks off the per-project write loop. A failed enqueue surfaces for Directory Sync, whose worker retries the idempotent effect, and is swallowed for sign-in, where the next login enqueues again. Environment creation now tolerates a concurrent creator so the project-creation loop and the job cannot collide on the unique index. Also fixes environment resolution ignoring dev-environment ownership: a member without their own dev environment could be handed a colleague's and have it persisted as their dashboard preference.
183 lines
5.4 KiB
TypeScript
183 lines
5.4 KiB
TypeScript
import { randomBytes } from "node:crypto";
|
|
import { beforeEach, describe, expect, vi } from "vitest";
|
|
import type { PrismaClient } from "@trigger.dev/database";
|
|
|
|
const prismaHolder = vi.hoisted(() => ({
|
|
client: null as PrismaClient | null,
|
|
}));
|
|
|
|
type SetUserRoleResult = { ok: true } | { ok: false; error: string; code?: "last_owner" };
|
|
|
|
const rbacHolder = vi.hoisted(() => ({
|
|
setUserRoleResult: { ok: true } as SetUserRoleResult,
|
|
currentRole: null as { id: string } | null,
|
|
}));
|
|
|
|
vi.mock("~/services/rbac.server", () => ({
|
|
rbac: {
|
|
getUserRole: async () => rbacHolder.currentRole,
|
|
setUserRole: async () => rbacHolder.setUserRoleResult,
|
|
},
|
|
}));
|
|
|
|
const enqueueHolder = vi.hoisted(() => ({
|
|
calls: [] as unknown[],
|
|
enqueued: true,
|
|
}));
|
|
vi.mock("~/services/memberDevEnvironments.server", () => ({
|
|
enqueueMemberDevelopmentEnvironments: async (payload: unknown) => {
|
|
enqueueHolder.calls.push(payload);
|
|
return { enqueued: enqueueHolder.enqueued };
|
|
},
|
|
}));
|
|
const enqueueCalls = enqueueHolder.calls;
|
|
|
|
vi.mock("~/db.server", async () => {
|
|
const { Prisma } = await import("@trigger.dev/database");
|
|
|
|
return {
|
|
Prisma,
|
|
get prisma() {
|
|
if (!prismaHolder.client) {
|
|
throw new Error("test prisma not set");
|
|
}
|
|
return prismaHolder.client;
|
|
},
|
|
get $replica() {
|
|
if (!prismaHolder.client) {
|
|
throw new Error("test prisma not set");
|
|
}
|
|
return prismaHolder.client;
|
|
},
|
|
};
|
|
});
|
|
|
|
import { postgresTest } from "@internal/testcontainers";
|
|
|
|
vi.setConfig({ testTimeout: 60_000 });
|
|
|
|
beforeEach(() => {
|
|
enqueueCalls.length = 0;
|
|
enqueueHolder.enqueued = true;
|
|
rbacHolder.setUserRoleResult = { ok: true };
|
|
rbacHolder.currentRole = null;
|
|
});
|
|
|
|
function randomHex(len = 12): string {
|
|
return randomBytes(Math.ceil(len / 2))
|
|
.toString("hex")
|
|
.slice(0, len);
|
|
}
|
|
|
|
async function seedUserAndOrg(prisma: PrismaClient) {
|
|
const suffix = randomHex(8);
|
|
|
|
const owner = await prisma.user.create({
|
|
data: { email: `owner-${suffix}@test.local`, authenticationMethod: "MAGIC_LINK" },
|
|
});
|
|
const user = await prisma.user.create({
|
|
data: { email: `joiner-${suffix}@test.local`, authenticationMethod: "SSO" },
|
|
});
|
|
const organization = await prisma.organization.create({
|
|
data: {
|
|
title: `jit-org-${suffix}`,
|
|
slug: `jit-org-${suffix}`,
|
|
isActivated: true,
|
|
members: { create: { userId: owner.id, role: "ADMIN" } },
|
|
},
|
|
});
|
|
|
|
return { user, organization };
|
|
}
|
|
|
|
describe("ensureOrgMember development environment provisioning", () => {
|
|
postgresTest("queues provisioning for a newly created membership", async ({ prisma }) => {
|
|
prismaHolder.client = prisma;
|
|
const { ensureOrgMember } = await import("../app/models/orgMember.server");
|
|
|
|
const { user, organization } = await seedUserAndOrg(prisma);
|
|
|
|
const result = await ensureOrgMember({
|
|
userId: user.id,
|
|
organizationId: organization.id,
|
|
roleId: null,
|
|
source: "sso_jit",
|
|
});
|
|
|
|
expect(result.created).toBe(true);
|
|
expect(enqueueCalls).toEqual([
|
|
{ userId: user.id, organizationId: organization.id, source: "sso_jit" },
|
|
]);
|
|
});
|
|
|
|
postgresTest("queues provisioning for a membership that already exists", async ({ prisma }) => {
|
|
prismaHolder.client = prisma;
|
|
const { ensureOrgMember } = await import("../app/models/orgMember.server");
|
|
|
|
const { user, organization } = await seedUserAndOrg(prisma);
|
|
await prisma.orgMember.create({
|
|
data: { userId: user.id, organizationId: organization.id, role: "MEMBER" },
|
|
});
|
|
|
|
const result = await ensureOrgMember({
|
|
userId: user.id,
|
|
organizationId: organization.id,
|
|
roleId: null,
|
|
source: "directory_sync",
|
|
});
|
|
|
|
expect(result.created).toBe(false);
|
|
expect(enqueueCalls).toEqual([
|
|
{ userId: user.id, organizationId: organization.id, source: "directory_sync" },
|
|
]);
|
|
});
|
|
|
|
postgresTest(
|
|
"does not queue provisioning when the membership is rolled back",
|
|
async ({ prisma }) => {
|
|
prismaHolder.client = prisma;
|
|
const { ensureOrgMember } = await import("../app/models/orgMember.server");
|
|
|
|
const { user, organization } = await seedUserAndOrg(prisma);
|
|
rbacHolder.setUserRoleResult = { ok: false, error: "role service unavailable" };
|
|
|
|
await expect(
|
|
ensureOrgMember({
|
|
userId: user.id,
|
|
organizationId: organization.id,
|
|
roleId: "role_restricted",
|
|
source: "sso_jit",
|
|
})
|
|
).rejects.toThrow(/failed to apply role/);
|
|
|
|
const member = await prisma.orgMember.findFirst({
|
|
where: { userId: user.id, organizationId: organization.id },
|
|
});
|
|
expect(member).toBeNull();
|
|
expect(enqueueCalls).toEqual([]);
|
|
}
|
|
);
|
|
|
|
postgresTest("reports a failed enqueue without failing the membership", async ({ prisma }) => {
|
|
prismaHolder.client = prisma;
|
|
const { ensureOrgMember } = await import("../app/models/orgMember.server");
|
|
|
|
const { user, organization } = await seedUserAndOrg(prisma);
|
|
enqueueHolder.enqueued = false;
|
|
|
|
const result = await ensureOrgMember({
|
|
userId: user.id,
|
|
organizationId: organization.id,
|
|
roleId: null,
|
|
source: "directory_sync",
|
|
});
|
|
|
|
expect(result).toMatchObject({ created: true, devEnvironmentsQueued: false });
|
|
|
|
const member = await prisma.orgMember.findFirst({
|
|
where: { userId: user.id, organizationId: organization.id },
|
|
});
|
|
expect(member).not.toBeNull();
|
|
});
|
|
});
|