Test sign-up rules widget

This commit is contained in:
Konstantin Wohlwend 2026-02-04 11:22:40 -08:00
parent 457ff2bdbe
commit 6fcf1a888f
6 changed files with 716 additions and 11 deletions

View File

@ -1017,6 +1017,48 @@ async function seedDummyProject(options: DummyProjectSeedOptions) {
projectId: DUMMY_PROJECT_ID,
branchId: DEFAULT_BRANCH_ID,
branchConfigOverrideOverride: {
auth: {
signUpRulesDefaultAction: "allow",
signUpRules: {
"allow-dummy-domain": {
enabled: true,
displayName: "Allow @dummy.dev",
priority: 4,
condition: 'emailDomain == "dummy.dev"',
action: {
type: "allow",
},
},
"block-disposable-emails": {
enabled: true,
displayName: "Block disposable emails",
priority: 3,
condition: 'emailDomain.matches("(?i)mailinator\\\\.com|tempmail\\\\.com")',
action: {
type: "reject",
message: "Disposable emails are not allowed",
},
},
"restrict-free-domains": {
enabled: true,
displayName: "Restrict free email domains",
priority: 2,
condition: 'emailDomain in ["gmail.com", "yahoo.com", "outlook.com"]',
action: {
type: "restrict",
},
},
"log-test-prefix": {
enabled: true,
displayName: "Log test+ emails",
priority: 1,
condition: 'email.startsWith("test+")',
action: {
type: "log",
},
},
},
},
payments: paymentsBranchOverride as any,
apps: {
installed: typedFromEntries(typedEntries(ALL_APPS).map(([key]) => [key, { enabled: true }])),

View File

@ -0,0 +1,95 @@
import { createSignUpRuleContext } from "@/lib/cel-evaluator";
import { evaluateSignUpRulesWithTrace } from "@/lib/sign-up-rules";
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
import { adaptSchema, adminAuthTypeSchema, yupArray, yupBoolean, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
const AUTH_METHODS = ['password', 'otp', 'oauth', 'passkey'] as const;
const ACTION_TYPES = ['allow', 'reject', 'restrict', 'log'] as const;
const DECISION_TYPES = ['allow', 'reject', 'default-allow', 'default-reject'] as const;
const STATUS_TYPES = ['matched', 'not_matched', 'disabled', 'missing_condition', 'error'] as const;
export const POST = createSmartRouteHandler({
metadata: {
hidden: true,
},
request: yupObject({
auth: yupObject({
type: adminAuthTypeSchema.defined(),
tenancy: adaptSchema.defined(),
}),
body: yupObject({
email: yupString().optional(),
auth_method: yupString().oneOf(AUTH_METHODS).defined(),
oauth_provider: yupString().optional(),
}).defined(),
}),
response: yupObject({
statusCode: yupNumber().oneOf([200]).defined(),
bodyType: yupString().oneOf(["json"]).defined(),
body: yupObject({
context: yupObject({
email: yupString().defined(),
email_domain: yupString().defined(),
auth_method: yupString().oneOf(AUTH_METHODS).defined(),
oauth_provider: yupString().defined(),
}).defined(),
evaluations: yupArray(yupObject({
rule_id: yupString().defined(),
display_name: yupString().defined(),
enabled: yupBoolean().defined(),
condition: yupString().defined(),
status: yupString().oneOf(STATUS_TYPES).defined(),
action: yupObject({
type: yupString().oneOf(ACTION_TYPES).defined(),
message: yupString().optional(),
}).defined(),
error: yupString().optional(),
}).defined()).defined(),
outcome: yupObject({
should_allow: yupBoolean().defined(),
decision: yupString().oneOf(DECISION_TYPES).defined(),
decision_rule_id: yupString().nullable().defined(),
restricted_because_of_rule_id: yupString().nullable().defined(),
}).defined(),
}).defined(),
}),
handler: async (req) => {
const context = createSignUpRuleContext({
email: req.body.email,
authMethod: req.body.auth_method,
oauthProvider: req.body.oauth_provider,
});
const trace = evaluateSignUpRulesWithTrace(req.auth.tenancy, context);
return {
statusCode: 200 as const,
bodyType: "json" as const,
body: {
context: {
email: context.email,
email_domain: context.emailDomain,
auth_method: context.authMethod,
oauth_provider: context.oauthProvider,
},
evaluations: trace.evaluations.map((evaluation) => ({
rule_id: evaluation.ruleId,
display_name: evaluation.rule.displayName ?? "",
enabled: evaluation.rule.enabled !== false,
condition: evaluation.rule.condition ?? "",
status: evaluation.status,
action: {
type: evaluation.rule.action.type,
message: evaluation.rule.action.message,
},
...(evaluation.error ? { error: evaluation.error } : {}),
})),
outcome: {
should_allow: trace.outcome.shouldAllow,
decision: trace.outcome.decision,
decision_rule_id: trace.outcome.decisionRuleId,
restricted_because_of_rule_id: trace.outcome.restrictedBecauseOfSignUpRuleId,
},
},
};
},
});

View File

@ -1,5 +1,5 @@
import { runAsynchronouslyAndWaitUntil } from "@/utils/vercel";
import type { SignUpRuleAction } from "@stackframe/stack-shared/dist/interface/crud/sign-up-rules";
import type { SignUpRule, SignUpRuleAction } from "@stackframe/stack-shared/dist/interface/crud/sign-up-rules";
import { captureError, StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors";
import { typedEntries } from "@stackframe/stack-shared/dist/utils/objects";
import { CelEvaluationError, evaluateCelExpression, SignUpRuleContext } from "./cel-evaluator";
@ -54,17 +54,92 @@ export async function evaluateSignUpRules(
tenancy: Tenancy,
context: SignUpRuleContext
) {
const config = tenancy.config;
const result = evaluateSignUpRulesInternal(tenancy, context, { includeEvaluations: false, logTriggers: true });
return {
restrictedBecauseOfSignUpRuleId: result.outcome.restrictedBecauseOfSignUpRuleId,
shouldAllow: result.outcome.shouldAllow,
};
}
export type SignUpRuleEvaluationStatus =
| 'matched'
| 'not_matched'
| 'disabled'
| 'missing_condition'
| 'error';
export type SignUpRuleEvaluation = {
ruleId: string,
rule: SignUpRule,
status: SignUpRuleEvaluationStatus,
error?: string,
};
export type SignUpRulesTraceResult = {
evaluations: SignUpRuleEvaluation[],
outcome: {
shouldAllow: boolean,
decision: 'allow' | 'reject' | 'default-allow' | 'default-reject',
decisionRuleId: string | null,
restrictedBecauseOfSignUpRuleId: string | null,
},
};
/**
* Evaluates all sign-up rules and returns a trace of evaluations.
* This is used for admin testing and does not log any analytics events.
*/
export function evaluateSignUpRulesWithTrace(
tenancy: Tenancy,
context: SignUpRuleContext
): SignUpRulesTraceResult {
return evaluateSignUpRulesInternal(tenancy, context, { includeEvaluations: true, logTriggers: false });
}
function evaluateSignUpRulesInternal(
tenancy: Tenancy,
context: SignUpRuleContext,
options: { includeEvaluations: boolean, logTriggers: boolean }
): SignUpRulesTraceResult {
const config = tenancy.config;
const evaluations: SignUpRuleEvaluation[] = [];
let restrictedBecauseOfSignUpRuleId: string | null = null;
const recordEvaluation = options.includeEvaluations
? (evaluation: SignUpRuleEvaluation) => {
evaluations.push(evaluation);
}
: () => {};
for (const [ruleId, rule] of typedEntries(config.auth.signUpRules)) {
if (!rule.enabled || !rule.condition) continue;
const isEnabled = rule.enabled === true;
if (!isEnabled) {
recordEvaluation({
ruleId,
rule,
status: 'disabled',
});
continue;
}
if (!rule.condition) {
recordEvaluation({
ruleId,
rule,
status: 'missing_condition',
});
continue;
}
let matches = false;
let status: SignUpRuleEvaluationStatus = 'not_matched';
let error: string | undefined;
try {
matches = evaluateCelExpression(rule.condition, context);
status = matches ? 'matched' : 'not_matched';
} catch (e) {
if (e instanceof CelEvaluationError) {
status = 'error';
error = e.message;
// technically a custom config could cause this, but the dashboard shouldn't allow creating faulty configs
// so for now, let's capture an error so we know that something is probably wrong on the DB
captureError(`cel-evaluation-error:${ruleId}`, new StackAssertionError(`CEL evaluation error for rule ${ruleId}`, { cause: e }));
@ -73,6 +148,13 @@ export async function evaluateSignUpRules(
}
}
recordEvaluation({
ruleId,
rule,
status,
...(error ? { error } : {}),
});
if (matches) {
const actionConfig = rule.action;
const actionType = actionConfig.type;
@ -81,8 +163,10 @@ export async function evaluateSignUpRules(
message: actionConfig.message,
};
// log asynchronously
runAsynchronouslyAndWaitUntil(logRuleTrigger(tenancy, ruleId, context, action));
if (options.logTriggers) {
// log asynchronously
runAsynchronouslyAndWaitUntil(logRuleTrigger(tenancy, ruleId, context, action));
}
// apply the action
if (actionType === 'restrict') {
@ -93,15 +177,26 @@ export async function evaluateSignUpRules(
}
if (actionType === 'allow' || actionType === 'reject') {
return {
restrictedBecauseOfSignUpRuleId,
shouldAllow: actionType === 'allow',
evaluations,
outcome: {
restrictedBecauseOfSignUpRuleId,
shouldAllow: actionType === 'allow',
decision: actionType,
decisionRuleId: ruleId,
},
};
}
}
}
const shouldAllow = config.auth.signUpRulesDefaultAction !== 'reject';
return {
restrictedBecauseOfSignUpRuleId,
shouldAllow: config.auth.signUpRulesDefaultAction !== 'reject',
evaluations,
outcome: {
restrictedBecauseOfSignUpRuleId,
shouldAllow,
decision: shouldAllow ? 'default-allow' : 'default-reject',
decisionRuleId: null,
},
};
}

View File

@ -54,8 +54,12 @@ export async function createOrUpgradeAnonymousUserWithRules(
}
const existingRestrictionPrivateDetails = createOrUpdate.restricted_by_admin_private_details ?? currentUser?.restricted_by_admin_private_details;
const restrictionPrivateDetails = ruleResult.restrictedBecauseOfSignUpRuleId
? `Restricted by sign-up rule: ${ruleResult.restrictedBecauseOfSignUpRuleId}`
const restrictionRuleId = ruleResult.restrictedBecauseOfSignUpRuleId;
const restrictionRuleDisplayName = restrictionRuleId
? (tenancy.config.auth.signUpRules[restrictionRuleId].displayName ?? "")
: "";
const restrictionPrivateDetails = restrictionRuleId
? `Restricted by sign-up rule: ${restrictionRuleId}${restrictionRuleDisplayName ? ` (${restrictionRuleDisplayName})` : ""}`
: undefined;
const enrichedCreateOrUpdate = {

View File

@ -6,6 +6,13 @@ import {
Alert,
Button,
cn,
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
Input,
Select,
SelectContent,
@ -32,6 +39,7 @@ import type { CompleteConfig } from "@stackframe/stack-shared/dist/config/schema
import { useAsyncCallback } from "@stackframe/stack-shared/dist/hooks/use-async-callback";
import type { SignUpRule, SignUpRuleAction } from "@stackframe/stack-shared/dist/interface/crud/sign-up-rules";
import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors";
import { standardProviders } from "@stackframe/stack-shared/dist/utils/oauth";
import { typedEntries } from "@stackframe/stack-shared/dist/utils/objects";
import { runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/promises";
import { generateUuid } from "@stackframe/stack-shared/dist/utils/uuids";
@ -53,6 +61,44 @@ type SignUpRuleEntry = {
rule: SignUpRule,
};
type SignUpRulesTestEvaluationStatus =
| 'matched'
| 'not_matched'
| 'disabled'
| 'missing_condition'
| 'error';
type SignUpRulesTestEvaluation = {
rule_id: string,
display_name: string,
enabled: boolean,
condition: string,
status: SignUpRulesTestEvaluationStatus,
action: {
type: 'allow' | 'reject' | 'restrict' | 'log',
message?: string,
},
error?: string,
};
type SignUpRulesTestResult = {
context: {
email: string,
email_domain: string,
auth_method: 'password' | 'otp' | 'oauth' | 'passkey',
oauth_provider: string,
},
evaluations: SignUpRulesTestEvaluation[],
outcome: {
should_allow: boolean,
decision: 'allow' | 'reject' | 'default-allow' | 'default-reject',
decision_rule_id: string | null,
restricted_because_of_rule_id: string | null,
},
};
const OAUTH_PROVIDER_OPTIONS = Array.from(standardProviders);
// Get sorted rules from config
// Type assertion needed because schema changes take effect at build time
type ConfigWithSignUpRules = CompleteConfig & {
@ -467,6 +513,332 @@ function DefaultActionCard({
);
}
function TestRulesCard({
stackAdminApp,
}: {
stackAdminApp: ReturnType<typeof useAdminApp>,
}) {
const [email, setEmail] = useState('');
const [authMethod, setAuthMethod] = useState<SignUpRulesTestResult['context']['auth_method']>('password');
const [oauthProvider, setOauthProvider] = useState('');
const [result, setResult] = useState<SignUpRulesTestResult | null>(null);
const [runTest, isRunning] = useAsyncCallback(async () => {
const response = await (stackAdminApp as any)[stackAppInternalsSymbol].sendRequest(
'/internal/sign-up-rules-test',
{
method: 'POST',
body: JSON.stringify({
email: email || undefined,
auth_method: authMethod,
oauth_provider: authMethod === 'oauth' ? (oauthProvider || undefined) : undefined,
}),
headers: {
'Content-Type': 'application/json',
},
},
'admin'
);
if (!response.ok) {
throw new StackAssertionError(`Failed to test sign-up rules: ${response.status} ${response.statusText}`);
}
const data = await response.json();
setResult(data);
}, [authMethod, email, oauthProvider, stackAdminApp]);
const handleAuthMethodChange = (value: string) => {
if (value === 'password' || value === 'otp' || value === 'oauth' || value === 'passkey') {
setAuthMethod(value);
if (value !== 'oauth') {
setOauthProvider('');
}
}
};
const evaluations = result?.evaluations ?? [];
const matchedEvaluations = evaluations.filter((evaluation) => evaluation.status === 'matched');
const decisionRule = result?.outcome.decision_rule_id
? evaluations.find((evaluation) => evaluation.rule_id === result.outcome.decision_rule_id)
: undefined;
const restrictedRule = result?.outcome.restricted_because_of_rule_id
? evaluations.find((evaluation) => evaluation.rule_id === result.outcome.restricted_because_of_rule_id)
: undefined;
const outcomeLabel = result?.outcome.should_allow ? 'Allow' : 'Reject';
const outcomeTone = result?.outcome.should_allow
? "bg-green-500/10 text-green-700 dark:text-green-400 border-green-500/20"
: "bg-red-500/10 text-red-700 dark:text-red-400 border-red-500/20";
const actionBadgeClassName = (type: SignUpRulesTestEvaluation['action']['type']) => cn(
"text-[10px] font-bold uppercase tracking-wide px-2 py-0.5 rounded",
type === 'allow' && "bg-green-500/10 text-green-600 dark:text-green-400",
type === 'reject' && "bg-red-500/10 text-red-600 dark:text-red-400",
type === 'restrict' && "bg-yellow-500/10 text-yellow-600 dark:text-yellow-400",
type === 'log' && "bg-blue-500/10 text-blue-600 dark:text-blue-400",
);
const statusBadgeClassName = (status: SignUpRulesTestEvaluationStatus) => cn(
"text-[10px] font-bold uppercase tracking-wide px-2 py-0.5 rounded",
status === 'matched' && "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400",
status === 'not_matched' && "bg-muted/60 text-muted-foreground",
status === 'disabled' && "bg-muted/60 text-muted-foreground",
status === 'missing_condition' && "bg-amber-500/10 text-amber-600 dark:text-amber-400",
status === 'error' && "bg-red-500/10 text-red-600 dark:text-red-400",
);
const statusLabel: Record<SignUpRulesTestEvaluationStatus, string> = {
matched: 'Matched',
not_matched: 'No match',
disabled: 'Disabled',
missing_condition: 'No condition',
error: 'Error',
};
const decisionLabel: Record<SignUpRulesTestResult['outcome']['decision'], string> = {
allow: 'Allowed by rule',
reject: 'Rejected by rule',
'default-allow': 'Allowed by default',
'default-reject': 'Rejected by default',
};
return (
<div className="grid gap-4 lg:grid-cols-[minmax(0,1.2fr)_minmax(0,1fr)]">
<div className="space-y-3">
<div className="grid gap-3 md:grid-cols-2">
<div className="space-y-1.5">
<Typography variant="secondary" className="text-xs uppercase tracking-wide">
Email
</Typography>
<Input
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="user@company.com"
/>
</div>
<div className="space-y-1.5">
<Typography variant="secondary" className="text-xs uppercase tracking-wide">
Auth method
</Typography>
<Select value={authMethod} onValueChange={handleAuthMethodChange}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="password">Password</SelectItem>
<SelectItem value="otp">OTP</SelectItem>
<SelectItem value="oauth">OAuth</SelectItem>
<SelectItem value="passkey">Passkey</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-1.5">
<Typography variant="secondary" className="text-xs uppercase tracking-wide">
OAuth provider
</Typography>
<Input
value={oauthProvider}
onChange={(e) => setOauthProvider(e.target.value)}
placeholder={authMethod === 'oauth' ? "google" : "Only used for OAuth"}
disabled={authMethod !== 'oauth'}
list="sign-up-rule-test-oauth-providers"
/>
<datalist id="sign-up-rule-test-oauth-providers">
{OAUTH_PROVIDER_OPTIONS.map((provider) => (
<option key={provider} value={provider} />
))}
</datalist>
</div>
<div className="flex items-center gap-2">
<Button
size="sm"
onClick={() => runAsynchronouslyWithAlert(runTest)}
loading={isRunning}
>
Run test
</Button>
<Typography variant="secondary" className="text-xs">
Simulate a sign-up request to preview which rules trigger.
</Typography>
</div>
</div>
<div className="space-y-3">
{!result ? (
<Alert>
Run a test to preview which rules trigger and what the sign-up outcome would be.
</Alert>
) : (
<>
<div className={cn("rounded-lg border p-3 space-y-1", outcomeTone)}>
<div className="flex items-center justify-between">
<Typography className="text-sm font-semibold">Outcome</Typography>
<span className="text-xs font-semibold uppercase tracking-wide">{outcomeLabel}</span>
</div>
<Typography variant="secondary" className="text-xs">
{decisionLabel[result.outcome.decision]}
</Typography>
{decisionRule && (
<Typography variant="secondary" className="text-xs">
Decision rule: {decisionRule.display_name || decisionRule.rule_id}
</Typography>
)}
{decisionRule?.action.message && (
<Typography variant="secondary" className="text-xs">
Rejection reason: {decisionRule.action.message}
</Typography>
)}
{restrictedRule && (
<Typography variant="secondary" className="text-xs">
Restricted by: {restrictedRule.display_name || restrictedRule.rule_id}
</Typography>
)}
</div>
<div className="rounded-lg border p-3 space-y-2">
<div className="flex items-center justify-between">
<Typography className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Triggered rules
</Typography>
<Typography variant="secondary" className="text-[11px]">
{matchedEvaluations.length} matched
</Typography>
</div>
{matchedEvaluations.length === 0 ? (
<Typography variant="secondary" className="text-xs">
No rules matched. Default action applies.
</Typography>
) : (
<div className="space-y-2">
{matchedEvaluations.map((evaluation) => (
<div
key={evaluation.rule_id}
className="flex items-center justify-between gap-2 rounded-md bg-background/60 px-2.5 py-2 ring-1 ring-foreground/[0.04]"
>
<div className="min-w-0">
<Typography className="text-xs font-medium truncate">
{evaluation.display_name || evaluation.rule_id}
</Typography>
<Typography variant="secondary" className="text-[10px] truncate">
{evaluation.condition || "No condition"}
</Typography>
</div>
<div className="flex items-center gap-1.5 flex-shrink-0">
<span className={actionBadgeClassName(evaluation.action.type)}>
{evaluation.action.type}
</span>
{evaluation.rule_id === result.outcome.decision_rule_id && (
<span className="text-[10px] font-bold uppercase tracking-wide px-2 py-0.5 rounded bg-foreground/5 text-foreground">
Decision
</span>
)}
{evaluation.rule_id === result.outcome.restricted_because_of_rule_id && (
<span className="text-[10px] font-bold uppercase tracking-wide px-2 py-0.5 rounded bg-yellow-500/10 text-yellow-600 dark:text-yellow-400">
Restrict
</span>
)}
</div>
</div>
))}
</div>
)}
</div>
<div className="rounded-lg border p-3 space-y-2">
<div className="flex items-center justify-between">
<Typography className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Evaluation trace
</Typography>
<Typography variant="secondary" className="text-[11px]">
{evaluations.length} evaluated
</Typography>
</div>
{evaluations.length === 0 ? (
<Typography variant="secondary" className="text-xs">
No rules configured yet.
</Typography>
) : (
<div className="space-y-1 max-h-48 overflow-auto pr-1">
{evaluations.map((evaluation) => (
<div
key={evaluation.rule_id}
className="flex items-center justify-between gap-2 rounded-md px-2 py-1.5 hover:bg-muted/40 transition-colors hover:transition-none"
title={evaluation.error ?? undefined}
>
<div className="min-w-0">
<Typography className="text-xs font-medium truncate">
{evaluation.display_name || evaluation.rule_id}
</Typography>
<Typography variant="secondary" className="text-[10px] truncate">
{evaluation.condition || "No condition"}
</Typography>
</div>
<div className="flex items-center gap-1.5 flex-shrink-0">
<span className={actionBadgeClassName(evaluation.action.type)}>
{evaluation.action.type}
</span>
<span className={statusBadgeClassName(evaluation.status)}>
{statusLabel[evaluation.status]}
</span>
</div>
</div>
))}
</div>
)}
</div>
<div className="rounded-lg border p-3 space-y-1">
<Typography className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Normalized context
</Typography>
<Typography variant="secondary" className="text-xs">
Email: {result.context.email || "(empty)"}
</Typography>
<Typography variant="secondary" className="text-xs">
Email domain: {result.context.email_domain || "(empty)"}
</Typography>
<Typography variant="secondary" className="text-xs">
OAuth provider: {result.context.oauth_provider || "(empty)"}
</Typography>
</div>
</>
)}
</div>
</div>
);
}
function TestRulesDialog({
stackAdminApp,
}: {
stackAdminApp: ReturnType<typeof useAdminApp>,
}) {
return (
<Dialog>
<DialogTrigger asChild>
<Button size="sm" variant="secondary">
Open tester
</Button>
</DialogTrigger>
<DialogContent className="max-w-5xl">
<DialogHeader>
<DialogTitle>Test sign-up rules</DialogTitle>
<DialogDescription>
Simulate a sign-up request to see which rules trigger and how the final decision is made.
</DialogDescription>
</DialogHeader>
<DialogBody>
<TestRulesCard stackAdminApp={stackAdminApp} />
</DialogBody>
</DialogContent>
</Dialog>
);
}
// Delete confirmation dialog
function DeleteRuleDialog({
open,
@ -848,6 +1220,19 @@ export default function PageClient() {
value={defaultAction}
onChange={(v) => runAsynchronouslyWithAlert(handleDefaultActionChange(v))}
/>
<div className="pt-10">
<div className="relative rounded-xl border border-dashed border-border/70 bg-muted/10 p-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<Typography className="text-sm font-semibold">Test rules</Typography>
<Typography variant="secondary" className="text-xs">
Try sample sign-ups without touching the live flow.
</Typography>
</div>
<TestRulesDialog stackAdminApp={stackAdminApp} />
</div>
</div>
</div>
</div>
{/* Delete confirmation dialog */}

View File

@ -0,0 +1,84 @@
import { describe } from "vitest";
import { it } from "../../../../../helpers";
import { Project, niceBackendFetch } from "../../../../backend-helpers";
describe("with admin access", () => {
it("uses default action when no rules match", async ({ expect }) => {
await Project.createAndSwitch({ config: {} });
const response = await niceBackendFetch("/api/v1/internal/sign-up-rules-test", {
method: "POST",
accessType: "admin",
body: {
email: "user@example.com",
auth_method: "password",
},
});
expect(response.status).toBe(200);
expect(response.body).toMatchObject({
evaluations: [],
outcome: {
should_allow: true,
decision: "default-allow",
decision_rule_id: null,
restricted_because_of_rule_id: null,
},
});
});
it("returns a decision rule when an allow/reject rule matches", async ({ expect }) => {
await Project.createAndSwitch();
await Project.updateConfig({
"auth.signUpRules.log-first": {
enabled: true,
displayName: "Log first",
priority: 2,
condition: 'emailDomain == "example.com"',
action: {
type: "log",
},
},
"auth.signUpRules.block-oauth": {
enabled: true,
displayName: "Block OAuth",
priority: 1,
condition: 'authMethod == "oauth"',
action: {
type: "reject",
message: "OAuth blocked",
},
},
"auth.signUpRulesDefaultAction": "allow",
});
const response = await niceBackendFetch("/api/v1/internal/sign-up-rules-test", {
method: "POST",
accessType: "admin",
body: {
email: "test@example.com",
auth_method: "oauth",
oauth_provider: "google",
},
});
expect(response.status).toBe(200);
expect(response.body?.outcome).toMatchObject({
should_allow: false,
decision: "reject",
decision_rule_id: "block-oauth",
restricted_because_of_rule_id: null,
});
expect(response.body?.evaluations.map((evaluation: { rule_id: string }) => evaluation.rule_id)).toEqual([
"log-first",
"block-oauth",
]);
expect(response.body?.evaluations[0]).toMatchObject({
status: "matched",
action: { type: "log" },
});
expect(response.body?.evaluations[1]).toMatchObject({
status: "matched",
action: { type: "reject" },
});
});
});