First site audit over MCP fails with a raw billing error (#525)
This commit is contained in:
parent
25c8f7be26
commit
8db0dd3246
@ -1,12 +1,16 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
const { isHostedMock, hasManagedAccessMock, hasPaidPlanMock } = vi.hoisted(
|
const {
|
||||||
() => ({
|
isHostedMock,
|
||||||
|
hasManagedAccessMock,
|
||||||
|
hasPaidPlanMock,
|
||||||
|
getOrCreateCustomerMock,
|
||||||
|
} = vi.hoisted(() => ({
|
||||||
isHostedMock: vi.fn(),
|
isHostedMock: vi.fn(),
|
||||||
hasManagedAccessMock: vi.fn(),
|
hasManagedAccessMock: vi.fn(),
|
||||||
hasPaidPlanMock: vi.fn(),
|
hasPaidPlanMock: vi.fn(),
|
||||||
}),
|
getOrCreateCustomerMock: vi.fn(),
|
||||||
);
|
}));
|
||||||
|
|
||||||
vi.mock("cloudflare:workers", () => ({ env: {} }));
|
vi.mock("cloudflare:workers", () => ({ env: {} }));
|
||||||
vi.mock("@/server/lib/runtime-env", () => ({
|
vi.mock("@/server/lib/runtime-env", () => ({
|
||||||
@ -15,6 +19,7 @@ vi.mock("@/server/lib/runtime-env", () => ({
|
|||||||
vi.mock("@/server/billing/subscription", () => ({
|
vi.mock("@/server/billing/subscription", () => ({
|
||||||
customerHasManagedAccess: hasManagedAccessMock,
|
customerHasManagedAccess: hasManagedAccessMock,
|
||||||
customerHasPaidPlan: hasPaidPlanMock,
|
customerHasPaidPlan: hasPaidPlanMock,
|
||||||
|
getOrCreateOrganizationCustomer: getOrCreateCustomerMock,
|
||||||
}));
|
}));
|
||||||
vi.mock("@/server/features/audit/repositories/AuditRepository", () => ({
|
vi.mock("@/server/features/audit/repositories/AuditRepository", () => ({
|
||||||
AuditRepository: {},
|
AuditRepository: {},
|
||||||
@ -26,6 +31,12 @@ vi.mock("@/server/lib/audit/progress-kv", () => ({ AuditProgressKV: {} }));
|
|||||||
|
|
||||||
import { AuditService } from "@/server/features/audit/services/AuditService";
|
import { AuditService } from "@/server/features/audit/services/AuditService";
|
||||||
|
|
||||||
|
const customer = {
|
||||||
|
organizationId: "org-1",
|
||||||
|
userEmail: "user@example.com",
|
||||||
|
userId: "user-1",
|
||||||
|
};
|
||||||
|
|
||||||
describe("resolveAuditLimitTier", () => {
|
describe("resolveAuditLimitTier", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
@ -36,10 +47,20 @@ describe("resolveAuditLimitTier", () => {
|
|||||||
it("uses the uncapped self-hosted tier without consulting billing", async () => {
|
it("uses the uncapped self-hosted tier without consulting billing", async () => {
|
||||||
isHostedMock.mockResolvedValue(false);
|
isHostedMock.mockResolvedValue(false);
|
||||||
|
|
||||||
await expect(AuditService.resolveAuditLimitTier("org-1")).resolves.toBe(
|
await expect(AuditService.resolveAuditLimitTier(customer)).resolves.toBe(
|
||||||
"self_hosted",
|
"self_hosted",
|
||||||
);
|
);
|
||||||
|
expect(getOrCreateCustomerMock).not.toHaveBeenCalled();
|
||||||
expect(hasManagedAccessMock).not.toHaveBeenCalled();
|
expect(hasManagedAccessMock).not.toHaveBeenCalled();
|
||||||
expect(hasPaidPlanMock).not.toHaveBeenCalled();
|
expect(hasPaidPlanMock).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("ensures the Autumn customer before checking entitlements in hosted mode", async () => {
|
||||||
|
isHostedMock.mockResolvedValue(true);
|
||||||
|
|
||||||
|
await expect(AuditService.resolveAuditLimitTier(customer)).resolves.toBe(
|
||||||
|
"paid",
|
||||||
|
);
|
||||||
|
expect(getOrCreateCustomerMock).toHaveBeenCalledWith(customer);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { env } from "cloudflare:workers";
|
|||||||
import {
|
import {
|
||||||
customerHasManagedAccess,
|
customerHasManagedAccess,
|
||||||
customerHasPaidPlan,
|
customerHasPaidPlan,
|
||||||
|
getOrCreateOrganizationCustomer,
|
||||||
type BillingCustomerContext,
|
type BillingCustomerContext,
|
||||||
} from "@/server/billing/subscription";
|
} from "@/server/billing/subscription";
|
||||||
import { AuditRepository } from "@/server/features/audit/repositories/AuditRepository";
|
import { AuditRepository } from "@/server/features/audit/repositories/AuditRepository";
|
||||||
@ -29,12 +30,16 @@ import { isHostedServerAuthMode } from "@/server/lib/runtime-env";
|
|||||||
// small audit at a time, paid keeps the full limits, and customers with no
|
// small audit at a time, paid keeps the full limits, and customers with no
|
||||||
// Autumn product at all are turned away. Self-hosted isn't gated.
|
// Autumn product at all are turned away. Self-hosted isn't gated.
|
||||||
async function resolveAuditLimitTier(
|
async function resolveAuditLimitTier(
|
||||||
organizationId: string,
|
customer: BillingCustomerContext,
|
||||||
): Promise<AuditLimitTier> {
|
): Promise<AuditLimitTier> {
|
||||||
if (!(await isHostedServerAuthMode())) return "self_hosted";
|
if (!(await isHostedServerAuthMode())) return "self_hosted";
|
||||||
|
// An org minted outside a billing path (better-auth hooks, MCP auth) has no
|
||||||
|
// Autumn customer yet, and `check` 404s instead of reporting no access — a
|
||||||
|
// brand-new MCP user's first audit failed with a raw billing error.
|
||||||
|
await getOrCreateOrganizationCustomer(customer);
|
||||||
const [hasManagedAccess, hasPaidPlan] = await Promise.all([
|
const [hasManagedAccess, hasPaidPlan] = await Promise.all([
|
||||||
customerHasManagedAccess(organizationId),
|
customerHasManagedAccess(customer.organizationId),
|
||||||
customerHasPaidPlan(organizationId),
|
customerHasPaidPlan(customer.organizationId),
|
||||||
]);
|
]);
|
||||||
if (!hasManagedAccess) {
|
if (!hasManagedAccess) {
|
||||||
throw new AppError("PAYMENT_REQUIRED", "Subscribe to run site audits");
|
throw new AppError("PAYMENT_REQUIRED", "Subscribe to run site audits");
|
||||||
|
|||||||
@ -87,9 +87,7 @@ export const runSiteAuditTool = {
|
|||||||
// many-minute wait, which chat agents handle badly. The app UI passes its
|
// many-minute wait, which chat agents handle badly. The app UI passes its
|
||||||
// own explicit lighthouseStrategy, so this default only governs agents.
|
// own explicit lighthouseStrategy, so this default only governs agents.
|
||||||
const lighthouseStrategy = (args.runLighthouse ?? false) ? "auto" : "none";
|
const lighthouseStrategy = (args.runLighthouse ?? false) ? "auto" : "none";
|
||||||
const limitTier = await AuditService.resolveAuditLimitTier(
|
const limitTier = await AuditService.resolveAuditLimitTier(context.billing);
|
||||||
context.auth.organizationId,
|
|
||||||
);
|
|
||||||
let auditId: string;
|
let auditId: string;
|
||||||
try {
|
try {
|
||||||
({ auditId } = await AuditService.startAudit({
|
({ auditId } = await AuditService.startAudit({
|
||||||
|
|||||||
@ -16,9 +16,7 @@ export const startAudit = createServerFn({ method: "POST" })
|
|||||||
.middleware(requireProjectContext)
|
.middleware(requireProjectContext)
|
||||||
.validator(startAuditSchema)
|
.validator(startAuditSchema)
|
||||||
.handler(async ({ data, context }) => {
|
.handler(async ({ data, context }) => {
|
||||||
const limitTier = await AuditService.resolveAuditLimitTier(
|
const limitTier = await AuditService.resolveAuditLimitTier(context);
|
||||||
context.organizationId,
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = await AuditService.startAudit({
|
const result = await AuditService.startAudit({
|
||||||
actorUserId: context.userId,
|
actorUserId: context.userId,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user