From 3e805b3cd4c59016e554378c6730b3d2b6cc2107 Mon Sep 17 00:00:00 2001 From: Oskar Otwinowski Date: Thu, 30 Jul 2026 18:38:57 +0200 Subject: [PATCH] fix(webapp): create dev environments for SSO and Directory Sync members 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. --- .../sso-directory-sync-dev-environments.md | 6 + apps/webapp/app/models/member.server.ts | 113 +++-- apps/webapp/app/models/orgMember.server.ts | 37 +- apps/webapp/app/models/organization.server.ts | 84 +++- apps/webapp/app/models/project.server.ts | 12 +- .../SelectBestEnvironmentPresenter.server.ts | 6 +- .../route.tsx | 25 +- .../route.tsx | 15 +- .../services/directorySyncEffects.server.ts | 36 +- .../services/memberDevEnvironments.server.ts | 105 +++++ .../app/utils/environmentAccess.test.ts | 54 +++ apps/webapp/app/utils/environmentAccess.ts | 20 + .../app/v3/accountsWebhookWorker.server.ts | 10 +- apps/webapp/app/v3/commonWorker.server.ts | 14 + .../test/directorySyncEffects.server.test.ts | 57 ++- .../test/envParamRoute.ownership.test.ts | 127 ++++++ apps/webapp/test/member.server.test.ts | 60 ++- .../test/memberDevEnvironments.server.test.ts | 411 ++++++++++++++++++ apps/webapp/test/orgMember.server.test.ts | 182 ++++++++ .../webapp/test/selectBestEnvironment.test.ts | 90 ++++ 20 files changed, 1390 insertions(+), 74 deletions(-) create mode 100644 .server-changes/sso-directory-sync-dev-environments.md create mode 100644 apps/webapp/app/services/memberDevEnvironments.server.ts create mode 100644 apps/webapp/app/utils/environmentAccess.test.ts create mode 100644 apps/webapp/app/utils/environmentAccess.ts create mode 100644 apps/webapp/test/envParamRoute.ownership.test.ts create mode 100644 apps/webapp/test/memberDevEnvironments.server.test.ts create mode 100644 apps/webapp/test/orgMember.server.test.ts create mode 100644 apps/webapp/test/selectBestEnvironment.test.ts diff --git a/.server-changes/sso-directory-sync-dev-environments.md b/.server-changes/sso-directory-sync-dev-environments.md new file mode 100644 index 00000000000..072482b8b19 --- /dev/null +++ b/.server-changes/sso-directory-sync-dev-environments.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: fix +--- + +Team members who join an organization through single sign-on or directory sync now get their own development environment for every project, so the dashboard's dev view and `trigger dev` become available to them without an admin having to set anything up. Members who were already missing one are repaired the next time their membership is synced from your identity provider. diff --git a/apps/webapp/app/models/member.server.ts b/apps/webapp/app/models/member.server.ts index 4167738413b..3be5f7ce09c 100644 --- a/apps/webapp/app/models/member.server.ts +++ b/apps/webapp/app/models/member.server.ts @@ -1,6 +1,10 @@ import type { Organization, OrgMember, Project } from "@trigger.dev/database"; +import { z } from "zod"; import { Prisma as PrismaNamespace, type Prisma, prisma } from "~/db.server"; -import { createEnvironment } from "./organization.server"; +import { + createDevelopmentEnvironmentForMember, + memberDevelopmentEnvironmentWhere, +} from "./organization.server"; import { customAlphabet } from "nanoid"; import { logger } from "~/services/logger.server"; import { getDefaultEnvironmentConcurrencyLimit } from "~/services/platform.v3.server"; @@ -13,6 +17,31 @@ export const INVITE_BLOCKED_DIRECTORY_MANAGED = export const ENV_SETUP_INCOMPLETE = "You joined the organization, but we couldn't finish setting up your development environments. Please try accepting the invite again, or contact support if this persists."; +/** How a membership came to exist. Also validates the queued job's payload. */ +export const MembershipSourceSchema = z.enum(["invite", "sso_jit", "directory_sync", "manual"]); + +export type MembershipSource = z.infer; + +/** Thrown when provisioning fails partway through; the membership is still valid. */ +export class DevEnvironmentProvisioningError extends Error { + readonly logLevel = "warn" as const; + + constructor( + message: string, + readonly context: { + source: MembershipSource; + organizationId: string; + orgMemberId: string; + failedProjectId?: string; + createdProjectIds: string[]; + }, + options?: { cause?: unknown } + ) { + super(message, options); + this.name = "DevEnvironmentProvisioningError"; + } +} + export function isAcceptInviteFormError(error: unknown): error is Error { return ( error instanceof Error && @@ -186,7 +215,7 @@ export async function getUsersInvites({ email }: { email: string }) { }); } -async function getProjectsMissingMemberDevelopmentEnvironments({ +export async function getProjectsMissingMemberDevelopmentEnvironments({ memberId, organizationId, projects, @@ -201,10 +230,11 @@ async function getProjectsMissingMemberDevelopmentEnvironments({ const existingEnvs = await prisma.runtimeEnvironment.findMany({ where: { - orgMemberId: memberId, organizationId, - type: "DEVELOPMENT", - projectId: { in: projects.map((project) => project.id) }, + ...memberDevelopmentEnvironmentWhere({ + orgMemberId: memberId, + projectId: { in: projects.map((project) => project.id) }, + }), }, select: { projectId: true }, }); @@ -214,15 +244,15 @@ async function getProjectsMissingMemberDevelopmentEnvironments({ } export async function provisionMemberDevelopmentEnvironments({ + source, inviteId, - user, member, organization, projects, maximumConcurrencyLimit, }: { - inviteId: string; - user: { id: string; email: string }; + source: MembershipSource; + inviteId?: string; member: OrgMember; organization: Pick; projects: Pick[]; @@ -233,7 +263,7 @@ export async function provisionMemberDevelopmentEnvironments({ organizationId: organization.id, projects, }); - const projectIds = projects.map((project) => project.id); + const requestedProjectIds = projects.map((project) => project.id); const createdProjectIds: string[] = []; let failedProjectId: string | undefined; let failedProjectIndex: number | undefined; @@ -243,39 +273,74 @@ export async function provisionMemberDevelopmentEnvironments({ failedProjectId = project.id; failedProjectIndex = index; - await createEnvironment({ + const { created } = await createDevelopmentEnvironmentForMember({ organization, project, - type: "DEVELOPMENT", - // We set this true but no backfill (yet!?) so never used - // for dev environments - isBranchableEnvironment: true, member, maximumConcurrencyLimit, }); - createdProjectIds.push(project.id); + if (created) { + createdProjectIds.push(project.id); + } failedProjectId = undefined; failedProjectIndex = undefined; } } catch (error) { - logger.error("acceptInvite: development environment creation failed after membership created", { + const message = + "provisionMemberDevelopmentEnvironments: development environment creation failed after membership created"; + const context = { + source, inviteId, - userId: user.id, + userId: member.userId, organizationId: organization.id, orgMemberId: member.id, - projectIds, + requestedProjectIds, failedProjectId, failedProjectIndex, - totalProjects: projectsNeedingEnvs.length, + projectsNeedingEnvs: projectsNeedingEnvs.length, createdProjectIds, error: error instanceof Error ? { name: error.name, message: error.message, stack: error.stack } : String(error), - }); + }; + + if (source === "invite") { + logger.error(message, context); + } else { + logger.warn(message, context); + } - throw new Error(ENV_SETUP_INCOMPLETE); + throw new DevEnvironmentProvisioningError( + `Failed to create development environments for org member ${member.id}`, + { + source, + organizationId: organization.id, + orgMemberId: member.id, + failedProjectId, + createdProjectIds, + }, + { cause: error } + ); + } +} + +/** Provisions inline and surfaces a failure to the joiner as a retryable message. */ +async function provisionInviteDevelopmentEnvironments(args: { + inviteId: string; + member: OrgMember; + organization: Pick; + projects: Pick[]; + maximumConcurrencyLimit: number; +}) { + try { + await provisionMemberDevelopmentEnvironments({ source: "invite", ...args }); + } catch (error) { + if (error instanceof DevEnvironmentProvisioningError) { + throw new Error(ENV_SETUP_INCOMPLETE, { cause: error }); + } + throw error; } } @@ -375,9 +440,8 @@ async function tryRecoverIncompleteInviteAccept({ "DEVELOPMENT" ); - await provisionMemberDevelopmentEnvironments({ + await provisionInviteDevelopmentEnvironments({ inviteId, - user, member, organization: member.organization, projects: missingProjects, @@ -484,9 +548,8 @@ export async function acceptInvite({ } } - await provisionMemberDevelopmentEnvironments({ + await provisionInviteDevelopmentEnvironments({ inviteId, - user, member, organization: invite.organization, projects: invite.organization.projects, diff --git a/apps/webapp/app/models/orgMember.server.ts b/apps/webapp/app/models/orgMember.server.ts index d3ec2153d79..2bcd24b109f 100644 --- a/apps/webapp/app/models/orgMember.server.ts +++ b/apps/webapp/app/models/orgMember.server.ts @@ -1,5 +1,7 @@ import { Prisma, prisma } from "~/db.server"; +import type { MembershipSource } from "~/models/member.server"; import { logger } from "~/services/logger.server"; +import { enqueueMemberDevelopmentEnvironments } from "~/services/memberDevEnvironments.server"; import { rbac } from "~/services/rbac.server"; import { getValidPersonalAccessTokens, @@ -13,10 +15,15 @@ export type EnsureOrgMemberParams = { // value is an RBAC role id; when an RBAC plugin is installed it gets // attached after the OrgMember row is created. roleId: string | null; - source: "sso_jit" | "invite" | "manual" | "directory_sync"; + source: MembershipSource; }; -export type EnsureOrgMemberResult = { created: boolean; orgMemberId: string }; +export type EnsureOrgMemberResult = { + created: boolean; + orgMemberId: string; + /** False when provisioning could not be queued; the membership is still valid. */ + devEnvironmentsQueued: boolean; +}; // Completes a JIT role assignment for an ALREADY-existing membership whose // RBAC role never got applied. This is a no-op when a role is already @@ -82,7 +89,12 @@ export async function ensureOrgMember( if (roleId !== null) { await healMissingRoleAssignment({ userId, organizationId, roleId, source }); } - return { created: false, orgMemberId: existing.id }; + const { enqueued } = await enqueueMemberDevelopmentEnvironments({ + userId, + organizationId, + source, + }); + return { created: false, orgMemberId: existing.id, devEnvironmentsQueued: enqueued }; } // Two concurrent JIT/invite flows can both miss the findFirst above and @@ -106,7 +118,16 @@ export async function ensureOrgMember( select: { id: true }, }); if (existingAfterConflict) { - return { created: false, orgMemberId: existingAfterConflict.id }; + const { enqueued } = await enqueueMemberDevelopmentEnvironments({ + userId, + organizationId, + source, + }); + return { + created: false, + orgMemberId: existingAfterConflict.id, + devEnvironmentsQueued: enqueued, + }; } } throw error; @@ -134,7 +155,13 @@ export async function ensureOrgMember( } } - return { created: true, orgMemberId: member.id }; + const { enqueued } = await enqueueMemberDevelopmentEnvironments({ + userId, + organizationId, + source, + }); + + return { created: true, orgMemberId: member.id, devEnvironmentsQueued: enqueued }; } // Find-or-create a User for a directory-provisioned member. Directory Sync diff --git a/apps/webapp/app/models/organization.server.ts b/apps/webapp/app/models/organization.server.ts index 9f7fb382315..7022b60978b 100644 --- a/apps/webapp/app/models/organization.server.ts +++ b/apps/webapp/app/models/organization.server.ts @@ -10,7 +10,12 @@ import { tryCatch } from "@trigger.dev/core/utils"; import { customAlphabet } from "nanoid"; import { generate } from "random-words"; import slug from "slug"; -import { $replica, prisma, type PrismaClientOrTransaction } from "~/db.server"; +import { + $replica, + Prisma as PrismaNamespace, + prisma, + type PrismaClientOrTransaction, +} from "~/db.server"; import { env } from "~/env.server"; import { featuresForUrl } from "~/features.server"; import { createApiKeyForEnv, createPkApiKeyForEnv, envSlug } from "./api-key.server"; @@ -224,6 +229,83 @@ export async function createEnvironment({ return environment; } +/** + * A member's root development environment for a project, never a branch under + * it. Not keyed on slug, so a legacy root with another slug still matches. + */ +export function memberDevelopmentEnvironmentWhere({ + projectId, + orgMemberId, +}: { + projectId?: string | { in: string[] }; + orgMemberId: string; +}): Prisma.RuntimeEnvironmentWhereInput { + return { + ...(projectId === undefined ? {} : { projectId }), + orgMemberId, + type: "DEVELOPMENT", + parentEnvironmentId: null, + }; +} + +/** + * Create a member's development environment, reporting `created: false` when a + * concurrent writer already made it. Any other conflict still throws. + * + * Not transaction-aware: a unique violation aborts an enclosing transaction, so + * the read that confirms the concurrent row has to run outside one. + */ +export async function createDevelopmentEnvironmentForMember({ + organization, + project, + member, + maximumConcurrencyLimit, +}: { + organization: Pick; + project: Pick; + member: OrgMember; + maximumConcurrencyLimit?: number; +}): Promise<{ created: boolean }> { + try { + await createEnvironment({ + organization, + project, + type: "DEVELOPMENT", + isBranchableEnvironment: true, + member, + maximumConcurrencyLimit, + }); + return { created: true }; + } catch (error) { + if ( + !(error instanceof PrismaNamespace.PrismaClientKnownRequestError) || + error.code !== "P2002" + ) { + throw error; + } + + const existing = await prisma.runtimeEnvironment.findFirst({ + where: memberDevelopmentEnvironmentWhere({ + projectId: project.id, + orgMemberId: member.id, + }), + select: { id: true }, + }); + + if (!existing) { + throw error; + } + + logger.debug("Development environment already created by a concurrent writer", { + organizationId: organization.id, + projectId: project.id, + orgMemberId: member.id, + }); + + return { created: false }; + } +} + function createShortcode() { return generate({ exactly: 2 }); } diff --git a/apps/webapp/app/models/project.server.ts b/apps/webapp/app/models/project.server.ts index b579fcd2e22..2ed317fe879 100644 --- a/apps/webapp/app/models/project.server.ts +++ b/apps/webapp/app/models/project.server.ts @@ -4,7 +4,11 @@ import slug from "slug"; import { $replica, prisma } from "~/db.server"; import { projectCreated } from "~/services/projectCreated.server"; import { ServiceValidationError } from "~/v3/services/common.server"; -import { type Organization, createEnvironment } from "./organization.server"; +import { + type Organization, + createDevelopmentEnvironmentForMember, + createEnvironment, +} from "./organization.server"; export type { Project } from "@trigger.dev/database"; const externalRefGenerator = customAlphabet("abcdefghijklmnopqrstuvwxyz", 20); @@ -129,13 +133,9 @@ export async function createProject( }); for (const member of project.organization.members) { - await createEnvironment({ + await createDevelopmentEnvironmentForMember({ organization, project, - type: "DEVELOPMENT", - // We set this true but no backfill (yet!?) so never used - // for dev environments - isBranchableEnvironment: true, member, }); } diff --git a/apps/webapp/app/presenters/SelectBestEnvironmentPresenter.server.ts b/apps/webapp/app/presenters/SelectBestEnvironmentPresenter.server.ts index 1273c668c42..13d86911927 100644 --- a/apps/webapp/app/presenters/SelectBestEnvironmentPresenter.server.ts +++ b/apps/webapp/app/presenters/SelectBestEnvironmentPresenter.server.ts @@ -6,6 +6,7 @@ import { import { prisma } from "~/db.server"; import { logger } from "~/services/logger.server"; import { type UserFromSession } from "~/services/session.server"; +import { selectAccessibleEnvironment } from "~/utils/environmentAccess"; export type MinimumEnvironment = Pick & { orgMember: null | { @@ -154,7 +155,10 @@ export class SelectBestEnvironmentPresenter { const currentEnvironmentId: string | undefined = user.dashboardPreferences.projects[projectId]?.currentEnvironment.id; - const currentEnvironment = environments.find((env) => env.id === currentEnvironmentId); + const currentEnvironment = selectAccessibleEnvironment( + environments.filter((env) => env.id === currentEnvironmentId), + user.id + ); if (currentEnvironment) { return currentEnvironment; } diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam/route.tsx index d22883caa12..c09238b6d3f 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam/route.tsx @@ -7,6 +7,7 @@ import { updateCurrentProjectEnvironmentId } from "~/services/dashboardPreferenc import { logger } from "~/services/logger.server"; import { requireUser } from "~/services/session.server"; import { tenantContext } from "~/services/tenantContext.server"; +import { selectAccessibleEnvironment } from "~/utils/environmentAccess"; import { EnvironmentParamSchema, v3ProjectPath } from "~/utils/pathBuilder"; import { canAccessDashboardAgent } from "~/v3/canAccessDashboardAgent.server"; @@ -52,28 +53,14 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { } const environments = project.environments.filter((env) => env.slug === envParam); - if (environments.length === 0) { + const environment = selectAccessibleEnvironment(environments, user.id); + + if (!environment) { return redirect(v3ProjectPath({ slug: organizationSlug }, { slug: projectParam })); } - let environmentId: string | undefined = undefined; - let environmentType: "DEVELOPMENT" | "PREVIEW" | "STAGING" | "PRODUCTION" | undefined; - - if (environments.length > 1) { - const bestEnvironment = environments.find((env) => env.orgMember?.userId === user.id); - if (!bestEnvironment) { - throw new Response("Environment not Found", { - status: 404, - statusText: "Environment not found", - }); - } - - environmentId = bestEnvironment.id; - environmentType = bestEnvironment.type; - } else { - environmentId = environments[0].id; - environmentType = environments[0].type; - } + const environmentId = environment.id; + const environmentType = environment.type; // userId is enriched higher up in `_app/route.tsx`; only stamp tenant fields here. tenantContext.enrich({ diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.sso/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.sso/route.tsx index 46abc2d4f2b..3d88220c4c2 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.sso/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.sso/route.tsx @@ -42,6 +42,7 @@ import { ssoController } from "~/services/sso.server"; import { getSsoEntitlement } from "~/services/platform.v3.server"; import type { DirectorySyncEffect, DirectorySyncStatus, Role } from "@trigger.dev/plugins"; import { applyDirectorySyncEffects } from "~/services/directorySyncEffects.server"; +import { logger } from "~/services/logger.server"; import { flag } from "~/v3/featureFlags.server"; import { FEATURE_FLAG } from "~/v3/featureFlags"; import { dashboardAction, dashboardLoader } from "~/services/routeBuilders/dashboardBuilder"; @@ -328,7 +329,19 @@ export const action = dashboardAction( effects.push(...result.value.effects); } if (effects.length > 0) { - await applyDirectorySyncEffects(effects); + // The save itself has already been persisted, so a provisioning + // enqueue that failed must not turn it into an error page. There is no + // retry on this path; the members are repaired on their next sync. + const { unqueuedUserIds } = await applyDirectorySyncEffects(effects); + if (unqueuedUserIds.length > 0) { + logger.warn( + "directorySync: could not queue development environments after settings save", + { + organizationId: orgId, + userIds: unqueuedUserIds, + } + ); + } } return redirect(`/orgs/${params.organizationSlug}/settings/sso`); } diff --git a/apps/webapp/app/services/directorySyncEffects.server.ts b/apps/webapp/app/services/directorySyncEffects.server.ts index 555bb6f98ab..9ede3897a05 100644 --- a/apps/webapp/app/services/directorySyncEffects.server.ts +++ b/apps/webapp/app/services/directorySyncEffects.server.ts @@ -67,7 +67,8 @@ async function notifyLastOwnerProtected(userId: string, organizationId: string): } } -async function applyEffect(effect: DirectorySyncEffect): Promise { +/** Applies one effect, returning a user id that still needs provisioning queued. */ +async function applyEffect(effect: DirectorySyncEffect): Promise { switch (effect.kind) { case "provision": { const userId = @@ -80,7 +81,7 @@ async function applyEffect(effect: DirectorySyncEffect): Promise { }) ).userId; - await ensureOrgMember({ + const membership = await ensureOrgMember({ userId, organizationId: effect.organizationId, roleId: effect.roleId, @@ -111,7 +112,8 @@ async function applyEffect(effect: DirectorySyncEffect): Promise { } } } - return; + + return membership.devEnvironmentsQueued ? null : userId; } case "set_role": { const result = await rbac.setUserRole({ @@ -127,11 +129,11 @@ async function applyEffect(effect: DirectorySyncEffect): Promise { userId: effect.userId, organizationId: effect.organizationId, }); - return; + return null; } throw retryableEffectError(`directorySync set_role failed: ${result.error}`); } - return; + return null; } case "deprovision": { const outcome = await removeOrgMemberForDirectory({ @@ -141,20 +143,33 @@ async function applyEffect(effect: DirectorySyncEffect): Promise { if (!outcome.removed && outcome.reason === "last_owner_protected") { await notifyLastOwnerProtected(effect.userId, effect.organizationId); } - return; + return null; } } } +/** Raised for a batch whose provisioning could not be queued, when the caller can retry. */ +export function unqueuedProvisioningError(userIds: string[]): Error { + return retryableEffectError( + `directorySync could not queue development environments for users ${userIds.join(", ")}` + ); +} + /** * Applies membership effects, skipping any org that isn't entitled to SSO. * * An unreadable entitlement throws rather than skipping: effects are * idempotent and the worker retries, so retrying is lossless where dropping * would silently lose a directory change. + * + * Reports memberships whose provisioning could not be queued rather than + * throwing, so a caller with no retry can still finish successfully. */ -export async function applyDirectorySyncEffects(effects: DirectorySyncEffect[]): Promise { +export async function applyDirectorySyncEffects( + effects: DirectorySyncEffect[] +): Promise<{ unqueuedUserIds: string[] }> { const entitlements = new Map(); + const unqueuedUserIds: string[] = []; for (const effect of effects) { let entitlement = entitlements.get(effect.organizationId); @@ -177,6 +192,11 @@ export async function applyDirectorySyncEffects(effects: DirectorySyncEffect[]): continue; } - await applyEffect(effect); + const unqueuedUserId = await applyEffect(effect); + if (unqueuedUserId) { + unqueuedUserIds.push(unqueuedUserId); + } } + + return { unqueuedUserIds }; } diff --git a/apps/webapp/app/services/memberDevEnvironments.server.ts b/apps/webapp/app/services/memberDevEnvironments.server.ts new file mode 100644 index 00000000000..2c9dfc11a57 --- /dev/null +++ b/apps/webapp/app/services/memberDevEnvironments.server.ts @@ -0,0 +1,105 @@ +import { z } from "zod"; +import { prisma } from "~/db.server"; +import { + getProjectsMissingMemberDevelopmentEnvironments, + MembershipSourceSchema, + provisionMemberDevelopmentEnvironments, + type MembershipSource, +} from "~/models/member.server"; +import { logger } from "~/services/logger.server"; +import { getDefaultEnvironmentConcurrencyLimit } from "~/services/platform.v3.server"; + +export const MembershipDevEnvironmentsSchema = z.object({ + userId: z.string(), + organizationId: z.string(), + source: MembershipSourceSchema, +}); + +export type MembershipDevEnvironments = z.infer; + +/** + * Create the member's missing development environments, one per active project. + * Idempotent, so it is safe to re-run and to retry after a partial failure. + */ +export async function provisionDevEnvironmentsForMembership({ + userId, + organizationId, + source, +}: MembershipDevEnvironments): Promise { + const member = await prisma.orgMember.findFirst({ + where: { + userId, + organizationId, + organization: { deletedAt: null }, + }, + include: { + organization: { + include: { + projects: { where: { deletedAt: null }, select: { id: true } }, + }, + }, + }, + }); + + if (!member) { + logger.info("provisionDevEnvironmentsForMembership: no membership found", { + userId, + organizationId, + source, + }); + return; + } + + const projectsNeedingEnvs = await getProjectsMissingMemberDevelopmentEnvironments({ + memberId: member.id, + organizationId, + projects: member.organization.projects, + }); + + if (projectsNeedingEnvs.length === 0) { + return; + } + + const maximumConcurrencyLimit = await getDefaultEnvironmentConcurrencyLimit( + organizationId, + "DEVELOPMENT" + ); + + await provisionMemberDevelopmentEnvironments({ + source, + member, + organization: member.organization, + projects: projectsNeedingEnvs, + maximumConcurrencyLimit, + }); +} + +/** + * Queue provisioning, deduped per membership. Never throws: callers decide what + * `enqueued: false` means for them. + */ +export async function enqueueMemberDevelopmentEnvironments(payload: { + userId: string; + organizationId: string; + source: MembershipSource; +}): Promise<{ enqueued: boolean }> { + try { + // Lazy: a static import would close a module cycle. + const { commonWorker } = await import("~/v3/commonWorker.server"); + + await commonWorker.enqueueOnce({ + id: `membership:devEnvs:${payload.organizationId}:${payload.userId}`, + job: "membership.provisionDevEnvironments", + payload, + }); + + return { enqueued: true }; + } catch (error) { + logger.error("Failed to enqueue member development environment provisioning", { + ...payload, + error: error instanceof Error ? error.message : String(error), + }); + + return { enqueued: false }; + } +} diff --git a/apps/webapp/app/utils/environmentAccess.test.ts b/apps/webapp/app/utils/environmentAccess.test.ts new file mode 100644 index 00000000000..5e5b0f5a7e5 --- /dev/null +++ b/apps/webapp/app/utils/environmentAccess.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; +import { selectAccessibleEnvironment } from "./environmentAccess"; + +const USER_ID = "user_owner"; +const OTHER_USER_ID = "user_other"; + +function devEnvironment(id: string, userId: string | null) { + return { + id, + type: "DEVELOPMENT" as const, + orgMember: userId === null ? null : { userId }, + }; +} + +function prodEnvironment(id: string) { + return { id, type: "PRODUCTION" as const, orgMember: null }; +} + +describe("selectAccessibleEnvironment", () => { + it("returns the caller's own development environment", () => { + const own = devEnvironment("env_own", USER_ID); + + expect(selectAccessibleEnvironment([own], USER_ID)).toBe(own); + }); + + it("skips a development environment belonging to another member", () => { + const theirs = devEnvironment("env_theirs", OTHER_USER_ID); + + expect(selectAccessibleEnvironment([theirs], USER_ID)).toBeUndefined(); + }); + + it("picks the caller's development environment past another member's", () => { + const theirs = devEnvironment("env_theirs", OTHER_USER_ID); + const own = devEnvironment("env_own", USER_ID); + + expect(selectAccessibleEnvironment([theirs, own], USER_ID)).toBe(own); + }); + + it("skips a development environment whose member has been deleted", () => { + const orphaned = devEnvironment("env_orphaned", null); + + expect(selectAccessibleEnvironment([orphaned], USER_ID)).toBeUndefined(); + }); + + it("returns a shared environment regardless of who owns it", () => { + const prod = prodEnvironment("env_prod"); + + expect(selectAccessibleEnvironment([prod], USER_ID)).toBe(prod); + }); + + it("returns undefined when there are no environments", () => { + expect(selectAccessibleEnvironment([], USER_ID)).toBeUndefined(); + }); +}); diff --git a/apps/webapp/app/utils/environmentAccess.ts b/apps/webapp/app/utils/environmentAccess.ts new file mode 100644 index 00000000000..d68113854f5 --- /dev/null +++ b/apps/webapp/app/utils/environmentAccess.ts @@ -0,0 +1,20 @@ +import { type RuntimeEnvironmentType } from "@trigger.dev/database"; + +type AccessibleEnvironmentInput = { + type: RuntimeEnvironmentType; + orgMember: { userId: string } | null; +}; + +/** + * The first environment the user may actually use. Development environments are + * per-member, so resolving one by slug or stored id alone can return another + * member's; every other type is shared by the project. + */ +export function selectAccessibleEnvironment( + environments: T[], + userId: string +): T | undefined { + return environments.find( + (environment) => environment.type !== "DEVELOPMENT" || environment.orgMember?.userId === userId + ); +} diff --git a/apps/webapp/app/v3/accountsWebhookWorker.server.ts b/apps/webapp/app/v3/accountsWebhookWorker.server.ts index cdd3ab50d34..fb8fb88b5ac 100644 --- a/apps/webapp/app/v3/accountsWebhookWorker.server.ts +++ b/apps/webapp/app/v3/accountsWebhookWorker.server.ts @@ -4,7 +4,10 @@ import { env } from "~/env.server"; import { logger } from "~/services/logger.server"; import { singleton } from "~/utils/singleton"; import { ssoController } from "~/services/sso.server"; -import { applyDirectorySyncEffects } from "~/services/directorySyncEffects.server"; +import { + applyDirectorySyncEffects, + unqueuedProvisioningError, +} from "~/services/directorySyncEffects.server"; // Dedicated worker for inbound account-management webhooks. The webhook // proxy route verifies the signature via the plugin and enqueues the @@ -76,7 +79,10 @@ function initializeWorker() { // Directory-sync events return membership effects to apply against // public.* tables (the plugin never writes those). A throw here // bubbles to the worker for retry; effects are idempotent. - await applyDirectorySyncEffects(result.value.effects); + const { unqueuedUserIds } = await applyDirectorySyncEffects(result.value.effects); + if (unqueuedUserIds.length > 0) { + throw unqueuedProvisioningError(unqueuedUserIds); + } }, }, }); diff --git a/apps/webapp/app/v3/commonWorker.server.ts b/apps/webapp/app/v3/commonWorker.server.ts index 38f9ea94265..87093c36aae 100644 --- a/apps/webapp/app/v3/commonWorker.server.ts +++ b/apps/webapp/app/v3/commonWorker.server.ts @@ -12,6 +12,10 @@ import { runAttioWorkspaceSync, } from "~/services/attio.server"; import { logger } from "~/services/logger.server"; +import { + MembershipDevEnvironmentsSchema, + provisionDevEnvironmentsForMembership, +} from "~/services/memberDevEnvironments.server"; import { singleton } from "~/utils/singleton"; import { DeliverAlertService } from "./services/alerts/deliverAlert.server"; import { PerformDeploymentAlertsService } from "./services/alerts/performDeploymentAlerts.server"; @@ -58,6 +62,13 @@ function initializeWorker() { maxAttempts: 3, }, }, + "membership.provisionDevEnvironments": { + schema: MembershipDevEnvironmentsSchema, + visibilityTimeoutMs: 120_000, + retry: { + maxAttempts: 5, + }, + }, "v3.timeoutDeployment": { schema: z.object({ deploymentId: z.string(), @@ -155,6 +166,9 @@ function initializeWorker() { "attio.syncUser": async ({ payload }) => { await runAttioUserSync(payload); }, + "membership.provisionDevEnvironments": async ({ payload }) => { + await provisionDevEnvironmentsForMembership(payload); + }, "v3.timeoutDeployment": async ({ payload }) => { const service = new TimeoutDeploymentService(); await service.call(payload.deploymentId, payload.fromStatus, payload.errorMessage); diff --git a/apps/webapp/test/directorySyncEffects.server.test.ts b/apps/webapp/test/directorySyncEffects.server.test.ts index e8f9bb933cc..0d884c06355 100644 --- a/apps/webapp/test/directorySyncEffects.server.test.ts +++ b/apps/webapp/test/directorySyncEffects.server.test.ts @@ -50,7 +50,11 @@ function deprovision(organizationId: string): DirectorySyncEffect { describe("applyDirectorySyncEffects — SSO entitlement gate", () => { beforeEach(() => { vi.clearAllMocks(); - ensureOrgMember.mockResolvedValue(undefined); + ensureOrgMember.mockResolvedValue({ + created: true, + orgMemberId: "member_1", + devEnvironmentsQueued: true, + }); removeOrgMemberForDirectory.mockResolvedValue({ removed: true }); setUserRole.mockResolvedValue({ ok: true }); }); @@ -127,4 +131,55 @@ describe("applyDirectorySyncEffects — SSO entitlement gate", () => { expect.objectContaining({ organizationId: ENTITLED_ORG }) ); }); + + it("applies every effect in the batch and reports the unqueued members", async () => { + getSsoEntitlement.mockResolvedValue("entitled"); + ensureOrgMember + .mockResolvedValueOnce({ + created: true, + orgMemberId: "member_1", + devEnvironmentsQueued: false, + }) + .mockResolvedValueOnce({ + created: true, + orgMemberId: "member_2", + devEnvironmentsQueued: true, + }); + + const { unqueuedUserIds } = await applyDirectorySyncEffects([ + provision(ENTITLED_ORG, "a@acme.com"), + provision(ENTITLED_ORG, "b@acme.com"), + deprovision(ENTITLED_ORG), + ]); + + expect(unqueuedUserIds).toEqual(["user_1"]); + expect(ensureOrgMember).toHaveBeenCalledTimes(2); + expect(removeOrgMemberForDirectory).toHaveBeenCalledTimes(1); + }); + + it("applies the directory role even when provisioning could not be queued", async () => { + getSsoEntitlement.mockResolvedValue("entitled"); + ensureOrgMember.mockResolvedValue({ + created: false, + orgMemberId: "member_1", + devEnvironmentsQueued: false, + }); + + const effect = { ...provision(ENTITLED_ORG), roleId: "role_restricted" }; + + const { unqueuedUserIds } = await applyDirectorySyncEffects([effect]); + + expect(unqueuedUserIds).toEqual(["user_1"]); + expect(setUserRole).toHaveBeenCalledWith( + expect.objectContaining({ roleId: "role_restricted", organizationId: ENTITLED_ORG }) + ); + }); + + it("reports nothing to retry when every provision was queued", async () => { + getSsoEntitlement.mockResolvedValue("entitled"); + + const { unqueuedUserIds } = await applyDirectorySyncEffects([provision(ENTITLED_ORG)]); + + expect(unqueuedUserIds).toEqual([]); + }); }); diff --git a/apps/webapp/test/envParamRoute.ownership.test.ts b/apps/webapp/test/envParamRoute.ownership.test.ts new file mode 100644 index 00000000000..624ef711352 --- /dev/null +++ b/apps/webapp/test/envParamRoute.ownership.test.ts @@ -0,0 +1,127 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + user: { id: "user_me", admin: false, isImpersonating: false }, + project: null as unknown, + updatedEnvironmentIds: [] as string[], +})); + +vi.mock("~/db.server", () => ({ + prisma: { + project: { + findFirst: async () => mocks.project, + }, + }, + $replica: {}, +})); + +vi.mock("~/services/session.server", () => ({ + requireUser: async () => mocks.user, +})); + +vi.mock("~/services/dashboardPreferences.server", () => ({ + updateCurrentProjectEnvironmentId: async ({ environmentId }: { environmentId: string }) => { + mocks.updatedEnvironmentIds.push(environmentId); + }, +})); + +vi.mock("~/services/tenantContext.server", () => ({ + tenantContext: { enrich: () => {} }, +})); + +vi.mock("~/v3/canAccessDashboardAgent.server", () => ({ + canAccessDashboardAgent: async () => false, +})); + +vi.mock("~/services/logger.server", () => ({ + logger: { error: () => {}, warn: () => {}, info: () => {}, debug: () => {} }, +})); + +import { loader } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam/route"; + +const PARAMS = { + organizationSlug: "acme", + projectParam: "my-project", + envParam: "dev", +}; + +function devEnvironment(id: string, userId: string) { + return { id, type: "DEVELOPMENT" as const, slug: "dev", orgMember: { userId } }; +} + +function projectWith(environments: unknown[]) { + return { + id: "project_1", + externalRef: "proj_abc", + organization: { id: "org_1", featureFlags: {} }, + environments, + }; +} + +async function callLoader(params: typeof PARAMS = PARAMS) { + return (await loader({ + request: new Request( + `http://localhost:3030/orgs/acme/projects/my-project/env/${params.envParam}` + ), + params, + context: {}, + } as never)) as Response; +} + +describe("env.$envParam loader — development environment ownership", () => { + beforeEach(() => { + mocks.updatedEnvironmentIds.length = 0; + }); + + it("resolves the caller's own dev environment when several members have one", async () => { + mocks.project = projectWith([ + devEnvironment("env_colleague", "user_colleague"), + devEnvironment("env_mine", "user_me"), + ]); + + await callLoader(); + + expect(mocks.updatedEnvironmentIds).toEqual(["env_mine"]); + }); + + it("redirects rather than adopting the only other member's dev environment", async () => { + mocks.project = projectWith([devEnvironment("env_colleague", "user_colleague")]); + + const response = await callLoader(); + + expect(response.status).toBe(302); + expect(response.headers.get("location")).toBe("/orgs/acme/projects/my-project"); + expect(mocks.updatedEnvironmentIds).toEqual([]); + }); + + it("resolves a development branch to the caller's own, not a colleague's", async () => { + mocks.project = projectWith([ + { + id: "env_branch_theirs", + type: "DEVELOPMENT" as const, + slug: "dev-foo", + orgMember: { userId: "user_colleague" }, + }, + { + id: "env_branch_mine", + type: "DEVELOPMENT" as const, + slug: "dev-foo", + orgMember: { userId: "user_me" }, + }, + ]); + + await callLoader({ ...PARAMS, envParam: "dev-foo" }); + + expect(mocks.updatedEnvironmentIds).toEqual(["env_branch_mine"]); + }); + + it("still resolves shared environments that belong to no member", async () => { + mocks.project = projectWith([ + { id: "env_prod", type: "PRODUCTION" as const, slug: "dev", orgMember: null }, + ]); + + await callLoader(); + + expect(mocks.updatedEnvironmentIds).toEqual(["env_prod"]); + }); +}); diff --git a/apps/webapp/test/member.server.test.ts b/apps/webapp/test/member.server.test.ts index 722c49aff18..dd82f1e98a5 100644 --- a/apps/webapp/test/member.server.test.ts +++ b/apps/webapp/test/member.server.test.ts @@ -520,8 +520,8 @@ describe("provisionMemberDevelopmentEnvironments", () => { }); await provisionMemberDevelopmentEnvironments({ + source: "invite", inviteId: invite.id, - user: { id: invitee.id, email: invitee.email }, member, organization, projects: activeProjects, @@ -545,7 +545,7 @@ describe("provisionMemberDevelopmentEnvironments", () => { { timeout: 60_000 }, async ({ prisma }) => { prismaHolder.client = prisma; - const { provisionMemberDevelopmentEnvironments, ENV_SETUP_INCOMPLETE } = + const { provisionMemberDevelopmentEnvironments, DevEnvironmentProvisioningError } = await import("../app/models/member.server"); const { invitee, organization, activeProjects, invite } = await seedInviteFixture(prisma, { @@ -564,14 +564,13 @@ describe("provisionMemberDevelopmentEnvironments", () => { await expect( provisionMemberDevelopmentEnvironments({ - inviteId: invite.id, - user: { id: invitee.id, email: invitee.email }, + source: "sso_jit", member, organization, projects: [...activeProjects, { id: "missing-project-id" }], maximumConcurrencyLimit: 5, }) - ).rejects.toThrow(ENV_SETUP_INCOMPLETE); + ).rejects.toThrow(DevEnvironmentProvisioningError); const devEnvs = await prisma.runtimeEnvironment.findMany({ where: { @@ -588,6 +587,57 @@ describe("provisionMemberDevelopmentEnvironments", () => { ); }); +describe("acceptInvite environment provisioning failures", () => { + postgresTest( + "reports setup as incomplete and keeps the invite so it can be retried", + { timeout: 60_000 }, + async ({ prisma }) => { + prismaHolder.client = prisma; + const { acceptInvite, ENV_SETUP_INCOMPLETE, isAcceptInviteFormError } = + await import("../app/models/member.server"); + + const { invitee, organization, activeProjects, invite } = await seedInviteFixture(prisma, { + activeProjectCount: 2, + }); + + const member = await prisma.orgMember.create({ + data: { + organizationId: organization.id, + userId: invitee.id, + role: "MEMBER", + }, + }); + + const keys = devEnvKeys(`tr_stg_${randomHex(24)}`, `pk_stg_${randomHex(24)}`); + await prisma.runtimeEnvironment.create({ + data: { + slug: "dev", + type: "STAGING", + ...keys, + projectId: activeProjects[1].id, + organizationId: organization.id, + orgMemberId: member.id, + }, + }); + + const error = await acceptInvite({ + inviteId: invite.id, + organizationId: organization.id, + user: { id: invitee.id, email: invitee.email }, + }).catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toBe(ENV_SETUP_INCOMPLETE); + expect(isAcceptInviteFormError(error)).toBe(true); + + const remainingInvite = await prisma.orgMemberInvite.findFirst({ + where: { id: invite.id }, + }); + expect(remainingInvite).not.toBeNull(); + } + ); +}); + describe("acceptInvite recovery", () => { postgresTest( "retries successfully when membership exists and the invite is still pending", diff --git a/apps/webapp/test/memberDevEnvironments.server.test.ts b/apps/webapp/test/memberDevEnvironments.server.test.ts new file mode 100644 index 00000000000..57d7b07872c --- /dev/null +++ b/apps/webapp/test/memberDevEnvironments.server.test.ts @@ -0,0 +1,411 @@ +import { randomBytes } from "node:crypto"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { PrismaClient } from "@trigger.dev/database"; + +const prismaHolder = vi.hoisted(() => ({ + client: null as PrismaClient | null, +})); + +vi.mock("~/services/rbac.server", () => ({ + rbac: { + getUserRole: async () => null, + setUserRole: async () => ({ ok: true }), + }, +})); + +const workerHolder = vi.hoisted(() => ({ + calls: [] as Array<{ id: string; job: string; payload: unknown }>, + shouldThrow: false, +})); +vi.mock("~/v3/commonWorker.server", () => ({ + commonWorker: { + enqueueOnce: async (args: { id: string; job: string; payload: unknown }) => { + if (workerHolder.shouldThrow) { + throw new Error("redis unavailable"); + } + workerHolder.calls.push(args); + }, + }, +})); + +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 }); + +function randomHex(len = 12): string { + return randomBytes(Math.ceil(len / 2)) + .toString("hex") + .slice(0, len); +} + +async function seedMembershipFixture( + prisma: PrismaClient, + opts: { activeProjectCount: number; deletedProjectCount?: number } +) { + const suffix = randomHex(8); + + const user = await prisma.user.create({ + data: { + email: `member-${suffix}@test.local`, + authenticationMethod: "SSO", + }, + }); + + const organization = await prisma.organization.create({ + data: { + title: `sso-org-${suffix}`, + slug: `sso-org-${suffix}`, + isActivated: true, + members: { create: { userId: user.id, role: "MEMBER" } }, + }, + }); + + const activeProjects = []; + for (let i = 0; i < opts.activeProjectCount; i++) { + activeProjects.push( + await prisma.project.create({ + data: { + name: `active-project-${i}-${suffix}`, + slug: `active-proj-${i}-${suffix}`, + externalRef: `proj_active_${i}_${suffix}`, + organizationId: organization.id, + engine: "V2", + }, + }) + ); + } + + for (let i = 0; i < (opts.deletedProjectCount ?? 0); i++) { + await prisma.project.create({ + data: { + name: `deleted-project-${i}-${suffix}`, + slug: `deleted-proj-${i}-${suffix}`, + externalRef: `proj_deleted_${i}_${suffix}`, + organizationId: organization.id, + engine: "V2", + deletedAt: new Date(), + }, + }); + } + + return { user, organization, activeProjects }; +} + +function devEnvironmentsFor(prisma: PrismaClient, organizationId: string) { + return prisma.runtimeEnvironment.findMany({ + where: { organizationId, type: "DEVELOPMENT" }, + select: { projectId: true, orgMemberId: true, slug: true }, + }); +} + +describe("provisionDevEnvironmentsForMembership", () => { + postgresTest("creates a development environment for every active project", async ({ prisma }) => { + prismaHolder.client = prisma; + const { provisionDevEnvironmentsForMembership } = + await import("../app/services/memberDevEnvironments.server"); + + const { user, organization, activeProjects } = await seedMembershipFixture(prisma, { + activeProjectCount: 3, + }); + + await provisionDevEnvironmentsForMembership({ + userId: user.id, + organizationId: organization.id, + source: "sso_jit", + }); + + const devEnvs = await devEnvironmentsFor(prisma, organization.id); + const member = await prisma.orgMember.findFirstOrThrow({ + where: { userId: user.id, organizationId: organization.id }, + }); + + expect(devEnvs.map((env) => env.projectId).sort()).toEqual( + activeProjects.map((project) => project.id).sort() + ); + expect(devEnvs.every((env) => env.orgMemberId === member.id)).toBe(true); + expect(devEnvs.every((env) => env.slug === "dev")).toBe(true); + }); + + postgresTest("skips soft-deleted projects", async ({ prisma }) => { + prismaHolder.client = prisma; + const { provisionDevEnvironmentsForMembership } = + await import("../app/services/memberDevEnvironments.server"); + + const { user, organization, activeProjects } = await seedMembershipFixture(prisma, { + activeProjectCount: 2, + deletedProjectCount: 2, + }); + + await provisionDevEnvironmentsForMembership({ + userId: user.id, + organizationId: organization.id, + source: "directory_sync", + }); + + const devEnvs = await devEnvironmentsFor(prisma, organization.id); + + expect(devEnvs.map((env) => env.projectId).sort()).toEqual( + activeProjects.map((project) => project.id).sort() + ); + }); + + postgresTest("creates no duplicates when it runs again", async ({ prisma }) => { + prismaHolder.client = prisma; + const { provisionDevEnvironmentsForMembership } = + await import("../app/services/memberDevEnvironments.server"); + + const { user, organization, activeProjects } = await seedMembershipFixture(prisma, { + activeProjectCount: 2, + }); + + const payload = { + userId: user.id, + organizationId: organization.id, + source: "sso_jit" as const, + }; + + await provisionDevEnvironmentsForMembership(payload); + await provisionDevEnvironmentsForMembership(payload); + + const devEnvs = await devEnvironmentsFor(prisma, organization.id); + + expect(devEnvs).toHaveLength(activeProjects.length); + }); + + postgresTest("creates only the environments that are missing", async ({ prisma }) => { + prismaHolder.client = prisma; + const { provisionDevEnvironmentsForMembership } = + await import("../app/services/memberDevEnvironments.server"); + + const { user, organization, activeProjects } = await seedMembershipFixture(prisma, { + activeProjectCount: 3, + }); + const member = await prisma.orgMember.findFirstOrThrow({ + where: { userId: user.id, organizationId: organization.id }, + }); + + const existing = await prisma.runtimeEnvironment.create({ + data: { + slug: "dev", + type: "DEVELOPMENT", + apiKey: `tr_dev_${randomHex(24)}`, + pkApiKey: `pk_dev_${randomHex(24)}`, + shortcode: randomHex(4), + projectId: activeProjects[1].id, + organizationId: organization.id, + orgMemberId: member.id, + }, + }); + + await provisionDevEnvironmentsForMembership({ + userId: user.id, + organizationId: organization.id, + source: "sso_jit", + }); + + const devEnvs = await prisma.runtimeEnvironment.findMany({ + where: { organizationId: organization.id, type: "DEVELOPMENT" }, + select: { id: true, projectId: true }, + }); + + expect(devEnvs).toHaveLength(activeProjects.length); + expect(devEnvs.find((env) => env.projectId === activeProjects[1].id)?.id).toBe(existing.id); + }); + + postgresTest("does nothing when the membership no longer exists", async ({ prisma }) => { + prismaHolder.client = prisma; + const { provisionDevEnvironmentsForMembership } = + await import("../app/services/memberDevEnvironments.server"); + + const { user, organization } = await seedMembershipFixture(prisma, { + activeProjectCount: 2, + }); + + await prisma.orgMember.deleteMany({ + where: { userId: user.id, organizationId: organization.id }, + }); + + await expect( + provisionDevEnvironmentsForMembership({ + userId: user.id, + organizationId: organization.id, + source: "directory_sync", + }) + ).resolves.toBeUndefined(); + + expect(await devEnvironmentsFor(prisma, organization.id)).toHaveLength(0); + }); +}); + +describe("createDevelopmentEnvironmentForMember", () => { + postgresTest("creates the environment when the member has none", async ({ prisma }) => { + prismaHolder.client = prisma; + const { createDevelopmentEnvironmentForMember } = + await import("../app/models/organization.server"); + + const { user, organization, activeProjects } = await seedMembershipFixture(prisma, { + activeProjectCount: 1, + }); + const member = await prisma.orgMember.findFirstOrThrow({ + where: { userId: user.id, organizationId: organization.id }, + }); + + const result = await createDevelopmentEnvironmentForMember({ + organization, + project: activeProjects[0], + member, + maximumConcurrencyLimit: 5, + }); + + expect(result.created).toBe(true); + expect(await devEnvironmentsFor(prisma, organization.id)).toHaveLength(1); + }); + + postgresTest( + "treats a concurrently created environment as already provisioned", + async ({ prisma }) => { + prismaHolder.client = prisma; + const { createDevelopmentEnvironmentForMember } = + await import("../app/models/organization.server"); + + const { user, organization, activeProjects } = await seedMembershipFixture(prisma, { + activeProjectCount: 1, + }); + const member = await prisma.orgMember.findFirstOrThrow({ + where: { userId: user.id, organizationId: organization.id }, + }); + + const args = { + organization, + project: activeProjects[0], + member, + maximumConcurrencyLimit: 5, + }; + + const results = await Promise.all([ + createDevelopmentEnvironmentForMember(args), + createDevelopmentEnvironmentForMember(args), + ]); + + expect(results.filter((result) => result.created)).toHaveLength(1); + expect(results.filter((result) => !result.created)).toHaveLength(1); + expect(await devEnvironmentsFor(prisma, organization.id)).toHaveLength(1); + } + ); + + postgresTest( + "rethrows when the conflict is not a development environment", + async ({ prisma }) => { + prismaHolder.client = prisma; + const { createDevelopmentEnvironmentForMember } = + await import("../app/models/organization.server"); + + const { user, organization, activeProjects } = await seedMembershipFixture(prisma, { + activeProjectCount: 1, + }); + const member = await prisma.orgMember.findFirstOrThrow({ + where: { userId: user.id, organizationId: organization.id }, + }); + + await prisma.runtimeEnvironment.create({ + data: { + slug: "dev", + type: "STAGING", + apiKey: `tr_stg_${randomHex(24)}`, + pkApiKey: `pk_stg_${randomHex(24)}`, + shortcode: randomHex(4), + projectId: activeProjects[0].id, + organizationId: organization.id, + orgMemberId: member.id, + }, + }); + + await expect( + createDevelopmentEnvironmentForMember({ + organization, + project: activeProjects[0], + member, + maximumConcurrencyLimit: 5, + }) + ).rejects.toThrow(); + } + ); +}); + +describe("enqueueMemberDevelopmentEnvironments", () => { + beforeEach(() => { + workerHolder.calls.length = 0; + workerHolder.shouldThrow = false; + }); + + it("dedupes by organization and user so repeat sign-ins collapse", async () => { + const { enqueueMemberDevelopmentEnvironments } = + await import("../app/services/memberDevEnvironments.server"); + + const result = await enqueueMemberDevelopmentEnvironments({ + userId: "user_1", + organizationId: "org_1", + source: "sso_jit", + }); + + expect(result).toEqual({ enqueued: true }); + expect(workerHolder.calls).toEqual([ + { + id: "membership:devEnvs:org_1:user_1", + job: "membership.provisionDevEnvironments", + payload: { userId: "user_1", organizationId: "org_1", source: "sso_jit" }, + }, + ]); + }); + + it("reports a queue failure instead of throwing", async () => { + const { enqueueMemberDevelopmentEnvironments } = + await import("../app/services/memberDevEnvironments.server"); + workerHolder.shouldThrow = true; + + const result = await enqueueMemberDevelopmentEnvironments({ + userId: "user_1", + organizationId: "org_1", + source: "directory_sync", + }); + + expect(result).toEqual({ enqueued: false }); + }); +}); + +describe("membership source schema", () => { + it("accepts every membership source the job can be enqueued with", async () => { + const { MembershipSourceSchema } = await import("../app/models/member.server"); + const { MembershipDevEnvironmentsSchema } = + await import("../app/services/memberDevEnvironments.server"); + + for (const source of MembershipSourceSchema.options) { + const parsed = MembershipDevEnvironmentsSchema.safeParse({ + userId: "user_1", + organizationId: "org_1", + source, + }); + expect(parsed.success, `source "${source}" must be accepted by the job schema`).toBe(true); + } + }); +}); diff --git a/apps/webapp/test/orgMember.server.test.ts b/apps/webapp/test/orgMember.server.test.ts new file mode 100644 index 00000000000..e5664c9497a --- /dev/null +++ b/apps/webapp/test/orgMember.server.test.ts @@ -0,0 +1,182 @@ +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(); + }); +}); diff --git a/apps/webapp/test/selectBestEnvironment.test.ts b/apps/webapp/test/selectBestEnvironment.test.ts new file mode 100644 index 00000000000..2174a17af92 --- /dev/null +++ b/apps/webapp/test/selectBestEnvironment.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it, vi } from "vitest"; +import type { UserFromSession } from "~/services/session.server"; + +vi.mock("~/db.server", () => ({ prisma: {}, $replica: {} })); + +import { SelectBestEnvironmentPresenter } from "~/presenters/SelectBestEnvironmentPresenter.server"; + +const PROJECT_ID = "project_1"; +const USER_ID = "user_me"; +const OTHER_USER_ID = "user_colleague"; + +function userWithPreference(environmentId: string | undefined): UserFromSession { + return { + id: USER_ID, + dashboardPreferences: { + currentProjectId: PROJECT_ID, + projects: environmentId + ? { [PROJECT_ID]: { currentEnvironment: { id: environmentId } } } + : {}, + }, + } as unknown as UserFromSession; +} + +const myDev = { + id: "env_my_dev", + type: "DEVELOPMENT" as const, + slug: "dev", + parentEnvironmentId: null, + orgMember: { userId: USER_ID }, +}; + +const theirDev = { + id: "env_their_dev", + type: "DEVELOPMENT" as const, + slug: "dev", + parentEnvironmentId: null, + orgMember: { userId: OTHER_USER_ID }, +}; + +const prod = { + id: "env_prod", + type: "PRODUCTION" as const, + slug: "prod", + parentEnvironmentId: null, + orgMember: null, +}; + +describe("SelectBestEnvironmentPresenter.selectBestEnvironment", () => { + const presenter = new SelectBestEnvironmentPresenter({} as never); + + it("honours a stored preference that belongs to the user", async () => { + const result = await presenter.selectBestEnvironment(PROJECT_ID, userWithPreference(myDev.id), [ + theirDev, + myDev, + prod, + ]); + + expect(result).toBe(myDev); + }); + + it("ignores a stored preference pointing at another member's dev environment", async () => { + const result = await presenter.selectBestEnvironment( + PROJECT_ID, + userWithPreference(theirDev.id), + [theirDev, myDev, prod] + ); + + expect(result).toBe(myDev); + }); + + it("falls back to production when the user has no dev environment of their own", async () => { + const result = await presenter.selectBestEnvironment( + PROJECT_ID, + userWithPreference(theirDev.id), + [theirDev, prod] + ); + + expect(result).toBe(prod); + }); + + it("returns the user's own dev environment when no preference is stored", async () => { + const result = await presenter.selectBestEnvironment( + PROJECT_ID, + userWithPreference(undefined), + [theirDev, myDev, prod] + ); + + expect(result).toBe(myDev); + }); +});