chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,239 @@
|
||||
import type {
|
||||
DirectorySyncEffect,
|
||||
DirectorySyncStatus,
|
||||
OrgSsoStatus,
|
||||
SsoBeginError,
|
||||
SsoCompleteError,
|
||||
SsoController,
|
||||
SsoDecisionError,
|
||||
SsoFlow,
|
||||
SsoMutationError,
|
||||
SsoPortalError,
|
||||
SsoProfile,
|
||||
SsoResolutionDecision,
|
||||
SsoRouteDecision,
|
||||
SsoValidateError,
|
||||
SsoWebhookError,
|
||||
SsoWebhookEvent,
|
||||
} from "@trigger.dev/plugins";
|
||||
import { errAsync, okAsync, type ResultAsync } from "neverthrow";
|
||||
|
||||
// The default fallback used when no cloud SSO plugin is installed.
|
||||
// `decideRouteForEmail` returns no_sso so OSS deployments behave
|
||||
// identically to a deployment with no SSO feature at all. Mutation
|
||||
// methods return feature_disabled so callers can surface a clear
|
||||
// "not available" message in UI gated by `isUsingPlugin()`.
|
||||
//
|
||||
// The fallback never touches the database. It still accepts the loader's
|
||||
// Prisma input for signature parity with the real cloud plugin factory
|
||||
// (so the loader can swap implementations without changing its call),
|
||||
// but ignores it entirely.
|
||||
export class SsoFallback {
|
||||
constructor(_prisma?: unknown) {}
|
||||
|
||||
create(): SsoController {
|
||||
return new SsoFallbackController();
|
||||
}
|
||||
}
|
||||
|
||||
class SsoFallbackController implements SsoController {
|
||||
async isUsingPlugin(): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
|
||||
getStatus(_organizationId: string): ResultAsync<OrgSsoStatus, SsoDecisionError> {
|
||||
return okAsync({
|
||||
hasIdpOrg: false,
|
||||
enforced: false,
|
||||
jitProvisioningEnabled: false,
|
||||
jitDefaultRoleId: null,
|
||||
idpOrgId: null,
|
||||
primaryConnectionId: null,
|
||||
domains: [],
|
||||
connections: [],
|
||||
});
|
||||
}
|
||||
|
||||
generatePortalLink(_params: {
|
||||
organizationId: string;
|
||||
userId: string;
|
||||
intent: "sso" | "domain_verification" | "dsync";
|
||||
returnUrl: string;
|
||||
}): ResultAsync<{ url: string }, SsoPortalError> {
|
||||
return errAsync("idp_org_unavailable" as const);
|
||||
}
|
||||
|
||||
setEnforced(_params: {
|
||||
organizationId: string;
|
||||
enforced: boolean;
|
||||
}): ResultAsync<void, SsoMutationError> {
|
||||
return errAsync("feature_disabled" as const);
|
||||
}
|
||||
|
||||
setJitProvisioningEnabled(_params: {
|
||||
organizationId: string;
|
||||
enabled: boolean;
|
||||
}): ResultAsync<void, SsoMutationError> {
|
||||
return errAsync("feature_disabled" as const);
|
||||
}
|
||||
|
||||
setJitDefaultRole(_params: {
|
||||
organizationId: string;
|
||||
roleId: string | null;
|
||||
}): ResultAsync<void, SsoMutationError> {
|
||||
return errAsync("feature_disabled" as const);
|
||||
}
|
||||
|
||||
updateConfig(_params: {
|
||||
organizationId: string;
|
||||
enforced: boolean;
|
||||
jitProvisioningEnabled: boolean;
|
||||
jitDefaultRoleId: string | null;
|
||||
}): ResultAsync<void, SsoMutationError> {
|
||||
return errAsync("feature_disabled" as const);
|
||||
}
|
||||
|
||||
getDirectorySyncStatus(
|
||||
_organizationId: string
|
||||
): ResultAsync<DirectorySyncStatus, SsoDecisionError> {
|
||||
return okAsync({
|
||||
hasDirectory: false,
|
||||
hasActiveDirectory: false,
|
||||
allowExternalDomainSync: false,
|
||||
allowManualMembership: true,
|
||||
directoryDefaultRoleId: null,
|
||||
userCount: 0,
|
||||
directories: [],
|
||||
groups: [],
|
||||
});
|
||||
}
|
||||
|
||||
setDirectoryGroupRole(_params: {
|
||||
organizationId: string;
|
||||
groupId: string;
|
||||
roleId: string | null;
|
||||
}): ResultAsync<{ effects: DirectorySyncEffect[] }, SsoMutationError> {
|
||||
return errAsync("feature_disabled" as const);
|
||||
}
|
||||
|
||||
setDirectoryDefaultRole(_params: {
|
||||
organizationId: string;
|
||||
roleId: string | null;
|
||||
}): ResultAsync<void, SsoMutationError> {
|
||||
return errAsync("feature_disabled" as const);
|
||||
}
|
||||
|
||||
setAllowExternalDomainSync(_params: {
|
||||
organizationId: string;
|
||||
allowed: boolean;
|
||||
}): ResultAsync<void, SsoMutationError> {
|
||||
return errAsync("feature_disabled" as const);
|
||||
}
|
||||
|
||||
// OSS has no directory, so manual membership is always allowed.
|
||||
getMembershipPolicy(
|
||||
_organizationId: string
|
||||
): ResultAsync<{ manualMembershipAllowed: boolean }, SsoDecisionError> {
|
||||
return okAsync({ manualMembershipAllowed: true });
|
||||
}
|
||||
|
||||
setAllowManualMembership(_params: {
|
||||
organizationId: string;
|
||||
allowed: boolean;
|
||||
}): ResultAsync<void, SsoMutationError> {
|
||||
return errAsync("feature_disabled" as const);
|
||||
}
|
||||
|
||||
recordMembershipRemoval(_params: {
|
||||
organizationId: string;
|
||||
userId: string;
|
||||
reason: "manual_removal" | "self_leave";
|
||||
}): ResultAsync<void, SsoMutationError> {
|
||||
// No plugin → no JIT → nothing to guard against. No-op success.
|
||||
return okAsync(undefined as void);
|
||||
}
|
||||
|
||||
clearMembershipRemoval(_params: {
|
||||
organizationId: string;
|
||||
userId: string;
|
||||
}): ResultAsync<void, SsoMutationError> {
|
||||
return okAsync(undefined as void);
|
||||
}
|
||||
|
||||
decideRouteForEmail(_email: string): ResultAsync<SsoRouteDecision, SsoDecisionError> {
|
||||
return okAsync({ kind: "no_sso" as const });
|
||||
}
|
||||
|
||||
beginAuthorization(_params: {
|
||||
email: string;
|
||||
redirectTo: string;
|
||||
flow: SsoFlow;
|
||||
}): ResultAsync<{ url: string }, SsoBeginError> {
|
||||
return errAsync("feature_disabled" as const);
|
||||
}
|
||||
|
||||
completeAuthorization(_params: { code: string; state: string }): ResultAsync<
|
||||
{
|
||||
profile: SsoProfile;
|
||||
redirectTo: string;
|
||||
flow: SsoFlow;
|
||||
},
|
||||
SsoCompleteError
|
||||
> {
|
||||
return errAsync("connection_unknown" as const);
|
||||
}
|
||||
|
||||
completeIdpInitiatedAuthorization(_params: {
|
||||
code: string;
|
||||
}): ResultAsync<{ profile: SsoProfile; redirectTo: string }, SsoCompleteError> {
|
||||
return errAsync("connection_unknown" as const);
|
||||
}
|
||||
|
||||
// Fail-open: with no plugin there are no SSO sessions to invalidate,
|
||||
// and the host treats `valid: true` as "leave the session alone".
|
||||
validateSession(_params: {
|
||||
userId: string;
|
||||
idpOrgId: string;
|
||||
connectionId: string;
|
||||
}): ResultAsync<{ valid: boolean }, SsoValidateError> {
|
||||
return okAsync({ valid: true });
|
||||
}
|
||||
|
||||
resolveSsoIdentity(_params: {
|
||||
profile: SsoProfile;
|
||||
}): ResultAsync<SsoResolutionDecision, SsoMutationError> {
|
||||
return errAsync("feature_disabled" as const);
|
||||
}
|
||||
|
||||
attachSsoIdentity(_params: {
|
||||
userId: string;
|
||||
profile: SsoProfile;
|
||||
}): ResultAsync<void, SsoMutationError> {
|
||||
return errAsync("feature_disabled" as const);
|
||||
}
|
||||
|
||||
evaluateJit(_params: {
|
||||
userId: string;
|
||||
idpOrgId: string;
|
||||
}): ResultAsync<
|
||||
{ shouldProvision: boolean; organizationId: string; roleId: string | null },
|
||||
SsoMutationError
|
||||
> {
|
||||
return errAsync("feature_disabled" as const);
|
||||
}
|
||||
|
||||
verifyWebhook(_params: {
|
||||
rawBody: string;
|
||||
headers: Record<string, string>;
|
||||
}): ResultAsync<{ event: SsoWebhookEvent }, SsoWebhookError> {
|
||||
return errAsync("feature_disabled" as const);
|
||||
}
|
||||
|
||||
processWebhookEvent(
|
||||
_event: SsoWebhookEvent
|
||||
): ResultAsync<{ effects: DirectorySyncEffect[] }, SsoWebhookError> {
|
||||
// No plugin: nothing to verify or act on. The host's webhook proxy
|
||||
// already rejects unverified requests, so a call here just no-ops.
|
||||
return okAsync({ effects: [] });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
import type {
|
||||
DirectorySyncEffect,
|
||||
DirectorySyncStatus,
|
||||
OrgSsoStatus,
|
||||
PluginDatabaseConfig,
|
||||
SsoBeginError,
|
||||
SsoCompleteError,
|
||||
SsoController,
|
||||
SsoDecisionError,
|
||||
SsoFlow,
|
||||
SsoMutationError,
|
||||
SsoPlugin,
|
||||
SsoPortalError,
|
||||
SsoProfile,
|
||||
SsoResolutionDecision,
|
||||
SsoRouteDecision,
|
||||
SsoValidateError,
|
||||
SsoWebhookError,
|
||||
SsoWebhookEvent,
|
||||
} from "@trigger.dev/plugins";
|
||||
import type { PrismaClient } from "@trigger.dev/database";
|
||||
import { ResultAsync } from "neverthrow";
|
||||
import { SsoFallback } from "./fallback.js";
|
||||
export type { SsoController } from "@trigger.dev/plugins";
|
||||
|
||||
export type SsoPrismaInput = PrismaClient | { primary: PrismaClient; replica: PrismaClient };
|
||||
|
||||
export type SsoCreateOptions = {
|
||||
// When true, skip loading the plugin. Useful for tests and for
|
||||
// contributors who don't have the cloud plugin installed.
|
||||
forceFallback?: boolean;
|
||||
// Override the dynamic importer. Lets tests inject a fake plugin
|
||||
// module or a synthetic ERR_MODULE_NOT_FOUND failure without touching
|
||||
// the real plugin install on disk.
|
||||
importer?: (moduleName: string) => Promise<{ default: SsoPlugin }>;
|
||||
// Writer/reader connection URLs + pool sizes for a plugin that owns its
|
||||
// own database client, resolved by the host from its env so the plugin
|
||||
// follows the host's writer/replica topology. The fallback ignores this —
|
||||
// it queries through the Prisma clients passed as `SsoPrismaInput`.
|
||||
database?: PluginDatabaseConfig;
|
||||
};
|
||||
|
||||
// Loads the cloud plugin lazily; falls back to the OSS no-op
|
||||
// implementation if not installed. Synchronous create() avoids
|
||||
// top-level await (not supported in the webapp's CJS build).
|
||||
export class LazyController implements SsoController {
|
||||
private readonly _init: Promise<SsoController>;
|
||||
|
||||
constructor(prisma: SsoPrismaInput, options?: SsoCreateOptions) {
|
||||
this._init = this.load(prisma, options);
|
||||
}
|
||||
|
||||
private async load(prisma: SsoPrismaInput, options?: SsoCreateOptions): Promise<SsoController> {
|
||||
if (options?.forceFallback) {
|
||||
return new SsoFallback(prisma).create();
|
||||
}
|
||||
const moduleName = "@triggerdotdev/plugins/sso";
|
||||
const importer =
|
||||
options?.importer ?? ((m: string) => import(m) as Promise<{ default: SsoPlugin }>);
|
||||
try {
|
||||
const module = await importer(moduleName);
|
||||
const plugin: SsoPlugin = module.default;
|
||||
console.log("SSO: using plugin implementation");
|
||||
return plugin.create({ database: options?.database });
|
||||
} catch (err) {
|
||||
// Distinguish the two failure modes the dynamic import can hit:
|
||||
//
|
||||
// 1. The plugin itself is absent (no install) — expected on OSS
|
||||
// deployments. Quiet by default; logged when SSO_LOG_FALLBACK=1
|
||||
// so contributors can opt into a visible signal locally.
|
||||
// 2. The plugin module loaded but its initialization failed
|
||||
// (transitive dep missing, syntax error, …). Always logged
|
||||
// loudly because this indicates a real bug.
|
||||
//
|
||||
// Node throws ERR_MODULE_NOT_FOUND for both cases, so we
|
||||
// disambiguate by checking whether the missing specifier is the
|
||||
// plugin's own module name.
|
||||
const code = (err as NodeJS.ErrnoException | undefined)?.code;
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const isModuleNotFound = code === "ERR_MODULE_NOT_FOUND" || code === "MODULE_NOT_FOUND";
|
||||
const isPluginItselfMissing = isModuleNotFound && message.includes(moduleName);
|
||||
|
||||
if (!isPluginItselfMissing) {
|
||||
console.error(
|
||||
"SSO: plugin found but failed to load; falling back to default implementation",
|
||||
err
|
||||
);
|
||||
} else if (process.env.SSO_LOG_FALLBACK === "1" || process.env.SSO_LOG_FALLBACK === "true") {
|
||||
console.log("SSO: no plugin installed (ERR_MODULE_NOT_FOUND); using fallback");
|
||||
}
|
||||
return new SsoFallback(prisma).create();
|
||||
}
|
||||
}
|
||||
|
||||
private c(): Promise<SsoController> {
|
||||
return this._init;
|
||||
}
|
||||
|
||||
// Bridges a Promise<ResultAsync<T,E>> back into a ResultAsync<T,E>.
|
||||
// The `load()` method above always resolves (it catches and falls
|
||||
// back), so `this.c()` is safe to lift via fromSafePromise. Inner
|
||||
// controller methods are expected to never throw — they return
|
||||
// errors via the Result instead — so the .andThen flatten is total.
|
||||
private call<T, E>(factory: (c: SsoController) => ResultAsync<T, E>): ResultAsync<T, E> {
|
||||
return ResultAsync.fromSafePromise(this.c().then(factory)).andThen((r) => r);
|
||||
}
|
||||
|
||||
async isUsingPlugin(): Promise<boolean> {
|
||||
return (await this.c()).isUsingPlugin();
|
||||
}
|
||||
|
||||
getStatus(organizationId: string): ResultAsync<OrgSsoStatus, SsoDecisionError> {
|
||||
return this.call((c) => c.getStatus(organizationId));
|
||||
}
|
||||
|
||||
generatePortalLink(params: {
|
||||
organizationId: string;
|
||||
userId: string;
|
||||
intent: "sso" | "domain_verification" | "dsync";
|
||||
returnUrl: string;
|
||||
}): ResultAsync<{ url: string }, SsoPortalError> {
|
||||
return this.call((c) => c.generatePortalLink(params));
|
||||
}
|
||||
|
||||
setEnforced(params: {
|
||||
organizationId: string;
|
||||
enforced: boolean;
|
||||
}): ResultAsync<void, SsoMutationError> {
|
||||
return this.call((c) => c.setEnforced(params));
|
||||
}
|
||||
|
||||
setJitProvisioningEnabled(params: {
|
||||
organizationId: string;
|
||||
enabled: boolean;
|
||||
}): ResultAsync<void, SsoMutationError> {
|
||||
return this.call((c) => c.setJitProvisioningEnabled(params));
|
||||
}
|
||||
|
||||
setJitDefaultRole(params: {
|
||||
organizationId: string;
|
||||
roleId: string | null;
|
||||
}): ResultAsync<void, SsoMutationError> {
|
||||
return this.call((c) => c.setJitDefaultRole(params));
|
||||
}
|
||||
|
||||
updateConfig(params: {
|
||||
organizationId: string;
|
||||
enforced: boolean;
|
||||
jitProvisioningEnabled: boolean;
|
||||
jitDefaultRoleId: string | null;
|
||||
}): ResultAsync<void, SsoMutationError> {
|
||||
return this.call((c) => c.updateConfig(params));
|
||||
}
|
||||
|
||||
getDirectorySyncStatus(
|
||||
organizationId: string
|
||||
): ResultAsync<DirectorySyncStatus, SsoDecisionError> {
|
||||
return this.call((c) => c.getDirectorySyncStatus(organizationId));
|
||||
}
|
||||
|
||||
setDirectoryGroupRole(params: {
|
||||
organizationId: string;
|
||||
groupId: string;
|
||||
roleId: string | null;
|
||||
}): ResultAsync<{ effects: DirectorySyncEffect[] }, SsoMutationError> {
|
||||
return this.call((c) => c.setDirectoryGroupRole(params));
|
||||
}
|
||||
|
||||
setDirectoryDefaultRole(params: {
|
||||
organizationId: string;
|
||||
roleId: string | null;
|
||||
}): ResultAsync<void, SsoMutationError> {
|
||||
return this.call((c) => c.setDirectoryDefaultRole(params));
|
||||
}
|
||||
|
||||
setAllowExternalDomainSync(params: {
|
||||
organizationId: string;
|
||||
allowed: boolean;
|
||||
}): ResultAsync<void, SsoMutationError> {
|
||||
return this.call((c) => c.setAllowExternalDomainSync(params));
|
||||
}
|
||||
|
||||
getMembershipPolicy(
|
||||
organizationId: string
|
||||
): ResultAsync<{ manualMembershipAllowed: boolean }, SsoDecisionError> {
|
||||
return this.call((c) => c.getMembershipPolicy(organizationId));
|
||||
}
|
||||
|
||||
setAllowManualMembership(params: {
|
||||
organizationId: string;
|
||||
allowed: boolean;
|
||||
}): ResultAsync<void, SsoMutationError> {
|
||||
return this.call((c) => c.setAllowManualMembership(params));
|
||||
}
|
||||
|
||||
recordMembershipRemoval(params: {
|
||||
organizationId: string;
|
||||
userId: string;
|
||||
reason: "manual_removal" | "self_leave";
|
||||
}): ResultAsync<void, SsoMutationError> {
|
||||
return this.call((c) => c.recordMembershipRemoval(params));
|
||||
}
|
||||
|
||||
clearMembershipRemoval(params: {
|
||||
organizationId: string;
|
||||
userId: string;
|
||||
}): ResultAsync<void, SsoMutationError> {
|
||||
return this.call((c) => c.clearMembershipRemoval(params));
|
||||
}
|
||||
|
||||
decideRouteForEmail(email: string): ResultAsync<SsoRouteDecision, SsoDecisionError> {
|
||||
return this.call((c) => c.decideRouteForEmail(email));
|
||||
}
|
||||
|
||||
beginAuthorization(params: {
|
||||
email: string;
|
||||
redirectTo: string;
|
||||
flow: SsoFlow;
|
||||
}): ResultAsync<{ url: string }, SsoBeginError> {
|
||||
return this.call((c) => c.beginAuthorization(params));
|
||||
}
|
||||
|
||||
completeAuthorization(params: {
|
||||
code: string;
|
||||
state: string;
|
||||
}): ResultAsync<{ profile: SsoProfile; redirectTo: string; flow: SsoFlow }, SsoCompleteError> {
|
||||
return this.call((c) => c.completeAuthorization(params));
|
||||
}
|
||||
|
||||
completeIdpInitiatedAuthorization(params: {
|
||||
code: string;
|
||||
}): ResultAsync<{ profile: SsoProfile; redirectTo: string }, SsoCompleteError> {
|
||||
return this.call((c) => c.completeIdpInitiatedAuthorization(params));
|
||||
}
|
||||
|
||||
validateSession(params: {
|
||||
userId: string;
|
||||
idpOrgId: string;
|
||||
connectionId: string;
|
||||
}): ResultAsync<{ valid: boolean }, SsoValidateError> {
|
||||
return this.call((c) => c.validateSession(params));
|
||||
}
|
||||
|
||||
resolveSsoIdentity(params: {
|
||||
profile: SsoProfile;
|
||||
}): ResultAsync<SsoResolutionDecision, SsoMutationError> {
|
||||
return this.call((c) => c.resolveSsoIdentity(params));
|
||||
}
|
||||
|
||||
attachSsoIdentity(params: {
|
||||
userId: string;
|
||||
profile: SsoProfile;
|
||||
}): ResultAsync<void, SsoMutationError> {
|
||||
return this.call((c) => c.attachSsoIdentity(params));
|
||||
}
|
||||
|
||||
evaluateJit(params: {
|
||||
userId: string;
|
||||
idpOrgId: string;
|
||||
}): ResultAsync<
|
||||
{ shouldProvision: boolean; organizationId: string; roleId: string | null },
|
||||
SsoMutationError
|
||||
> {
|
||||
return this.call((c) => c.evaluateJit(params));
|
||||
}
|
||||
|
||||
verifyWebhook(params: {
|
||||
rawBody: string;
|
||||
headers: Record<string, string>;
|
||||
}): ResultAsync<{ event: SsoWebhookEvent }, SsoWebhookError> {
|
||||
return this.call((c) => c.verifyWebhook(params));
|
||||
}
|
||||
|
||||
processWebhookEvent(
|
||||
event: SsoWebhookEvent
|
||||
): ResultAsync<{ effects: DirectorySyncEffect[] }, SsoWebhookError> {
|
||||
return this.call((c) => c.processWebhookEvent(event));
|
||||
}
|
||||
}
|
||||
|
||||
class Sso {
|
||||
// Synchronous — returns a lazy controller that resolves any installed
|
||||
// plugin on first call.
|
||||
create(prisma: SsoPrismaInput, options?: SsoCreateOptions): SsoController {
|
||||
return new LazyController(prisma, options);
|
||||
}
|
||||
}
|
||||
|
||||
const loader = new Sso();
|
||||
|
||||
export default loader;
|
||||
@@ -0,0 +1,331 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type {
|
||||
OrgSsoStatus,
|
||||
SsoController,
|
||||
SsoFlow,
|
||||
SsoPlugin,
|
||||
SsoProfile,
|
||||
SsoResolutionDecision,
|
||||
SsoRouteDecision,
|
||||
} from "@trigger.dev/plugins";
|
||||
import { errAsync, okAsync, type ResultAsync } from "neverthrow";
|
||||
import loader, { LazyController } from "./index.js";
|
||||
|
||||
// A minimal stub controller used in the "plugin found" test path. Only
|
||||
// the methods the test cares about return useful values; the rest
|
||||
// return permissive defaults.
|
||||
function makeStubController(overrides: Partial<SsoController> = {}): SsoController {
|
||||
const stub: SsoController = {
|
||||
async isUsingPlugin() {
|
||||
return true;
|
||||
},
|
||||
getStatus(): ResultAsync<OrgSsoStatus, "internal"> {
|
||||
return okAsync({
|
||||
hasIdpOrg: true,
|
||||
enforced: false,
|
||||
jitProvisioningEnabled: true,
|
||||
jitDefaultRoleId: null,
|
||||
idpOrgId: "idp_stub",
|
||||
primaryConnectionId: null,
|
||||
domains: [],
|
||||
connections: [],
|
||||
});
|
||||
},
|
||||
generatePortalLink() {
|
||||
return okAsync({ url: "https://stub.example/portal" });
|
||||
},
|
||||
getDirectorySyncStatus() {
|
||||
return okAsync({
|
||||
hasDirectory: false,
|
||||
hasActiveDirectory: false,
|
||||
allowExternalDomainSync: false,
|
||||
allowManualMembership: true,
|
||||
directoryDefaultRoleId: null,
|
||||
userCount: 0,
|
||||
directories: [],
|
||||
groups: [],
|
||||
});
|
||||
},
|
||||
setDirectoryGroupRole() {
|
||||
return okAsync({ effects: [] });
|
||||
},
|
||||
setDirectoryDefaultRole() {
|
||||
return okAsync(undefined as void);
|
||||
},
|
||||
setAllowExternalDomainSync() {
|
||||
return okAsync(undefined as void);
|
||||
},
|
||||
getMembershipPolicy() {
|
||||
return okAsync({ manualMembershipAllowed: true });
|
||||
},
|
||||
setAllowManualMembership() {
|
||||
return okAsync(undefined as void);
|
||||
},
|
||||
recordMembershipRemoval() {
|
||||
return okAsync(undefined as void);
|
||||
},
|
||||
clearMembershipRemoval() {
|
||||
return okAsync(undefined as void);
|
||||
},
|
||||
setEnforced() {
|
||||
return okAsync(undefined as void);
|
||||
},
|
||||
setJitProvisioningEnabled() {
|
||||
return okAsync(undefined as void);
|
||||
},
|
||||
setJitDefaultRole() {
|
||||
return okAsync(undefined as void);
|
||||
},
|
||||
updateConfig() {
|
||||
return okAsync(undefined as void);
|
||||
},
|
||||
decideRouteForEmail(): ResultAsync<SsoRouteDecision, "internal"> {
|
||||
return okAsync<SsoRouteDecision, "internal">({
|
||||
kind: "sso_required",
|
||||
idpOrgId: "idp_stub",
|
||||
});
|
||||
},
|
||||
beginAuthorization() {
|
||||
return okAsync({ url: "https://stub.example/auth" });
|
||||
},
|
||||
completeAuthorization() {
|
||||
return errAsync("connection_unknown" as const);
|
||||
},
|
||||
completeIdpInitiatedAuthorization() {
|
||||
return errAsync("connection_unknown" as const);
|
||||
},
|
||||
resolveSsoIdentity(): ResultAsync<SsoResolutionDecision, "feature_disabled"> {
|
||||
return errAsync("feature_disabled" as const);
|
||||
},
|
||||
attachSsoIdentity() {
|
||||
return errAsync("feature_disabled" as const);
|
||||
},
|
||||
evaluateJit() {
|
||||
return errAsync("feature_disabled" as const);
|
||||
},
|
||||
validateSession() {
|
||||
return okAsync({ valid: true });
|
||||
},
|
||||
verifyWebhook() {
|
||||
return errAsync("invalid_signature" as const);
|
||||
},
|
||||
processWebhookEvent() {
|
||||
return okAsync({ effects: [] });
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
return stub;
|
||||
}
|
||||
|
||||
// Minimal Prisma stub. The fallback's only constructor work is to
|
||||
// record the input; nothing else here touches the database.
|
||||
const fakePrisma = {} as unknown as Parameters<typeof loader.create>[0];
|
||||
|
||||
describe("SSO LazyController", () => {
|
||||
describe("plugin missing (ERR_MODULE_NOT_FOUND on the plugin's own moduleName)", () => {
|
||||
it("falls back to the no-op implementation and reports isUsingPlugin=false", async () => {
|
||||
const importer = vi.fn(async (moduleName: string) => {
|
||||
const err = Object.assign(new Error(`Cannot find module '${moduleName}'`), {
|
||||
code: "ERR_MODULE_NOT_FOUND",
|
||||
});
|
||||
throw err;
|
||||
});
|
||||
|
||||
const controller = new LazyController(fakePrisma, { importer });
|
||||
|
||||
expect(await controller.isUsingPlugin()).toBe(false);
|
||||
const decision = await controller.decideRouteForEmail("anyone@example.com");
|
||||
expect(decision.isOk()).toBe(true);
|
||||
expect(decision._unsafeUnwrap()).toEqual({ kind: "no_sso" });
|
||||
});
|
||||
|
||||
it("does not log to console.log unless SSO_LOG_FALLBACK=1", async () => {
|
||||
const previous = process.env.SSO_LOG_FALLBACK;
|
||||
delete process.env.SSO_LOG_FALLBACK;
|
||||
const importer = vi.fn(async (moduleName: string) => {
|
||||
const err = Object.assign(new Error(`Cannot find module '${moduleName}'`), {
|
||||
code: "ERR_MODULE_NOT_FOUND",
|
||||
});
|
||||
throw err;
|
||||
});
|
||||
const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined);
|
||||
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||
|
||||
try {
|
||||
const controller = new LazyController(fakePrisma, { importer });
|
||||
await controller.isUsingPlugin();
|
||||
const fallbackLogs = logSpy.mock.calls.filter((args) =>
|
||||
args.some((a) => typeof a === "string" && a.includes("no plugin installed"))
|
||||
);
|
||||
expect(fallbackLogs.length).toBe(0);
|
||||
expect(errorSpy).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
logSpy.mockRestore();
|
||||
errorSpy.mockRestore();
|
||||
if (previous === undefined) delete process.env.SSO_LOG_FALLBACK;
|
||||
else process.env.SSO_LOG_FALLBACK = previous;
|
||||
}
|
||||
});
|
||||
|
||||
it("logs an info line when SSO_LOG_FALLBACK=1", async () => {
|
||||
const previous = process.env.SSO_LOG_FALLBACK;
|
||||
process.env.SSO_LOG_FALLBACK = "1";
|
||||
const importer = vi.fn(async (moduleName: string) => {
|
||||
const err = Object.assign(new Error(`Cannot find module '${moduleName}'`), {
|
||||
code: "ERR_MODULE_NOT_FOUND",
|
||||
});
|
||||
throw err;
|
||||
});
|
||||
const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined);
|
||||
|
||||
try {
|
||||
const controller = new LazyController(fakePrisma, { importer });
|
||||
await controller.isUsingPlugin();
|
||||
const fallbackLogs = logSpy.mock.calls.filter((args) =>
|
||||
args.some((a) => typeof a === "string" && a.includes("no plugin installed"))
|
||||
);
|
||||
expect(fallbackLogs.length).toBe(1);
|
||||
} finally {
|
||||
logSpy.mockRestore();
|
||||
if (previous === undefined) delete process.env.SSO_LOG_FALLBACK;
|
||||
else process.env.SSO_LOG_FALLBACK = previous;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("plugin broken (transitive dep missing or init error)", () => {
|
||||
it("logs a console.error and falls back", async () => {
|
||||
const importer = vi.fn(async () => {
|
||||
// Module-not-found from a *transitive* dep, not the plugin
|
||||
// itself — its `message` won't contain the plugin's moduleName.
|
||||
const err = Object.assign(new Error(`Cannot find module 'some-transitive-dep'`), {
|
||||
code: "ERR_MODULE_NOT_FOUND",
|
||||
});
|
||||
throw err;
|
||||
});
|
||||
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||
|
||||
try {
|
||||
const controller = new LazyController(fakePrisma, { importer });
|
||||
expect(await controller.isUsingPlugin()).toBe(false);
|
||||
expect(errorSpy).toHaveBeenCalled();
|
||||
const firstCallArgs = errorSpy.mock.calls[0]!;
|
||||
expect(
|
||||
firstCallArgs.some(
|
||||
(a) => typeof a === "string" && a.includes("plugin found but failed to load")
|
||||
)
|
||||
).toBe(true);
|
||||
} finally {
|
||||
errorSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("logs a console.error for non-module-not-found errors too", async () => {
|
||||
const importer = vi.fn(async () => {
|
||||
throw new SyntaxError("Unexpected token in plugin source");
|
||||
});
|
||||
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||
|
||||
try {
|
||||
const controller = new LazyController(fakePrisma, { importer });
|
||||
expect(await controller.isUsingPlugin()).toBe(false);
|
||||
expect(errorSpy).toHaveBeenCalled();
|
||||
} finally {
|
||||
errorSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("plugin found", () => {
|
||||
it("delegates isUsingPlugin to the plugin implementation", async () => {
|
||||
const stub = makeStubController();
|
||||
const plugin: SsoPlugin = { create: () => stub };
|
||||
const importer = vi.fn(async () => ({ default: plugin }));
|
||||
|
||||
const controller = new LazyController(fakePrisma, { importer });
|
||||
expect(await controller.isUsingPlugin()).toBe(true);
|
||||
});
|
||||
|
||||
it("delegates decideRouteForEmail and propagates the result", async () => {
|
||||
const stub = makeStubController();
|
||||
const plugin: SsoPlugin = { create: () => stub };
|
||||
const importer = vi.fn(async () => ({ default: plugin }));
|
||||
|
||||
const controller = new LazyController(fakePrisma, { importer });
|
||||
const decision = await controller.decideRouteForEmail("admin@example.com");
|
||||
expect(decision.isOk()).toBe(true);
|
||||
expect(decision._unsafeUnwrap()).toEqual({
|
||||
kind: "sso_required",
|
||||
idpOrgId: "idp_stub",
|
||||
});
|
||||
});
|
||||
|
||||
it("propagates plugin errors through ResultAsync", async () => {
|
||||
const stub = makeStubController();
|
||||
const plugin: SsoPlugin = { create: () => stub };
|
||||
const importer = vi.fn(async () => ({ default: plugin }));
|
||||
|
||||
const controller = new LazyController(fakePrisma, { importer });
|
||||
const profile: SsoProfile = {
|
||||
email: "user@example.com",
|
||||
firstName: null,
|
||||
lastName: null,
|
||||
idpSubjectId: "sub_x",
|
||||
idpOrgId: "idp_stub",
|
||||
idpConnectionId: "conn_x",
|
||||
};
|
||||
const result = await controller.resolveSsoIdentity({ profile });
|
||||
expect(result.isErr()).toBe(true);
|
||||
expect(result._unsafeUnwrapErr()).toBe("feature_disabled");
|
||||
});
|
||||
|
||||
it("loads the plugin module only once across many calls", async () => {
|
||||
const stub = makeStubController();
|
||||
const plugin: SsoPlugin = { create: () => stub };
|
||||
const importer = vi.fn(async () => ({ default: plugin }));
|
||||
|
||||
const controller = new LazyController(fakePrisma, { importer });
|
||||
await controller.isUsingPlugin();
|
||||
await controller.decideRouteForEmail("a@example.com");
|
||||
await controller.decideRouteForEmail("b@example.com");
|
||||
expect(importer).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("forceFallback option", () => {
|
||||
it("skips the importer entirely", async () => {
|
||||
const importer = vi.fn();
|
||||
const controller = new LazyController(fakePrisma, {
|
||||
forceFallback: true,
|
||||
importer: importer as never,
|
||||
});
|
||||
expect(await controller.isUsingPlugin()).toBe(false);
|
||||
expect(importer).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("loader.create() factory", () => {
|
||||
it("returns a working LazyController", async () => {
|
||||
const controller = loader.create(fakePrisma, { forceFallback: true });
|
||||
expect(await controller.isUsingPlugin()).toBe(false);
|
||||
const decision = await controller.decideRouteForEmail("x@example.com");
|
||||
expect(decision._unsafeUnwrap()).toEqual({ kind: "no_sso" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("fallback parameter shapes", () => {
|
||||
it("propagates SsoFlow through beginAuthorization (uses fallback for error path)", async () => {
|
||||
// Smoke test that `SsoFlow` typing flows through. Plugin not present →
|
||||
// beginAuthorization returns `feature_disabled` per the fallback.
|
||||
const controller = loader.create(fakePrisma, { forceFallback: true });
|
||||
const flow: SsoFlow = "user_initiated";
|
||||
const result = await controller.beginAuthorization({
|
||||
email: "x@example.com",
|
||||
redirectTo: "/",
|
||||
flow,
|
||||
});
|
||||
expect(result.isErr()).toBe(true);
|
||||
expect(result._unsafeUnwrapErr()).toBe("feature_disabled");
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user