test: add vitest baseline for critical payment and auth guards

This commit is contained in:
2026-04-03 18:15:18 +02:00
parent 2542748e82
commit 68416ed9de
12 changed files with 730 additions and 75 deletions

9
convex/ai-utils.ts Normal file
View File

@@ -0,0 +1,9 @@
type NodeCanvasRef = {
canvasId: string;
};
export function assertNodeBelongsToCanvasOrThrow(node: NodeCanvasRef, canvasId: string): void {
if (node.canvasId !== canvasId) {
throw new Error("Node does not belong to canvas");
}
}

View File

@@ -12,6 +12,7 @@ import {
IMAGE_MODELS,
} from "./openrouter";
import type { Id } from "./_generated/dataModel";
import { assertNodeBelongsToCanvasOrThrow } from "./ai-utils";
const MAX_IMAGE_RETRIES = 2;
@@ -526,9 +527,7 @@ export const generateImage = action({
if (!node) {
throw new Error("Node not found");
}
if (node.canvasId !== args.canvasId) {
throw new Error("Node does not belong to canvas");
}
assertNodeBelongsToCanvasOrThrow(node, args.canvasId);
const userId = canvas.ownerId;
const verifiedCanvasId = canvas._id;

View File

@@ -0,0 +1,49 @@
import type { Id } from "./_generated/dataModel";
type BatchNodeRecord = {
_id: Id<"nodes">;
canvasId: Id<"canvases">;
};
type BatchCanvasRecord = {
_id: Id<"canvases">;
ownerId: string;
};
type ValidateBatchArgs<N extends BatchNodeRecord> = {
userId: string;
nodeIds: Id<"nodes">[];
getNodeById: (nodeId: Id<"nodes">) => Promise<N | null>;
getCanvasById: (canvasId: Id<"canvases">) => Promise<BatchCanvasRecord | null>;
};
export async function validateBatchNodesForUserOrThrow<N extends BatchNodeRecord>(
args: ValidateBatchArgs<N>,
): Promise<{ canvasId: Id<"canvases">; nodes: N[] }> {
if (args.nodeIds.length === 0) {
throw new Error("Batch must contain at least one node id");
}
const nodes: N[] = [];
for (const nodeId of args.nodeIds) {
const node = await args.getNodeById(nodeId);
if (!node) {
throw new Error("Node not found");
}
nodes.push(node);
}
const canvasId = nodes[0].canvasId;
for (const node of nodes) {
if (node.canvasId !== canvasId) {
throw new Error("All nodes must belong to the same canvas");
}
}
const canvas = await args.getCanvasById(canvasId);
if (!canvas || canvas.ownerId !== args.userId) {
throw new Error("Canvas not found");
}
return { canvasId, nodes };
}

View File

@@ -3,6 +3,7 @@ import { v } from "convex/values";
import { requireAuth } from "./helpers";
import type { Doc, Id } from "./_generated/dataModel";
import { isAdjustmentNodeType } from "../lib/canvas-node-types";
import { validateBatchNodesForUserOrThrow } from "./batch-validation-utils";
import {
getCanvasConnectionValidationMessage,
validateCanvasConnectionPolicy,
@@ -45,29 +46,12 @@ async function getValidatedBatchNodesOrThrow(
userId: string,
nodeIds: Id<"nodes">[],
): Promise<{ canvasId: Id<"canvases">; nodes: Doc<"nodes">[] }> {
if (nodeIds.length === 0) {
throw new Error("Batch must contain at least one node id");
}
const nodes: Doc<"nodes">[] = [];
for (const nodeId of nodeIds) {
const node = await ctx.db.get(nodeId);
if (!node) {
throw new Error("Node not found");
}
nodes.push(node);
}
const canvasId = nodes[0].canvasId;
for (const node of nodes) {
if (node.canvasId !== canvasId) {
throw new Error("All nodes must belong to the same canvas");
}
}
await getCanvasOrThrow(ctx, canvasId, userId);
return { canvasId, nodes };
return await validateBatchNodesForUserOrThrow({
userId,
nodeIds,
getNodeById: (nodeId) => ctx.db.get(nodeId),
getCanvasById: (canvasId) => ctx.db.get(canvasId),
});
}
type NodeCreateMutationName =

71
convex/polar-utils.ts Normal file
View File

@@ -0,0 +1,71 @@
type IdempotencyScope =
| "topup_paid"
| "subscription_activated_cycle"
| "subscription_revoked";
type RegisterWebhookEventArgs = {
provider: "polar";
scope: IdempotencyScope;
idempotencyKey: string;
userId: string;
polarOrderId?: string;
polarSubscriptionId?: string;
};
type ExistingEventLookup = {
provider: "polar";
idempotencyKey: string;
};
type ExistingEventRecord = {
_id: string;
};
export type WebhookEventRepo = {
findByProviderAndKey: (
lookup: ExistingEventLookup,
) => Promise<ExistingEventRecord | null>;
insert: (args: RegisterWebhookEventArgs & { createdAt: number }) => Promise<void>;
};
export function buildSubscriptionCycleIdempotencyKey(args: {
polarSubscriptionId: string;
currentPeriodStart: number;
currentPeriodEnd: number;
}): string {
return `polar:subscription_cycle:${args.polarSubscriptionId}:${args.currentPeriodStart}:${args.currentPeriodEnd}`;
}
export function buildSubscriptionRevokedIdempotencyKey(args: {
userId: string;
polarSubscriptionId?: string;
}): string {
return args.polarSubscriptionId
? `polar:subscription_revoked:${args.polarSubscriptionId}`
: `polar:subscription_revoked:user:${args.userId}`;
}
export function buildTopUpPaidIdempotencyKey(polarOrderId: string): string {
return `polar:order_paid:${polarOrderId}`;
}
export async function registerWebhookEventOnce(
repo: WebhookEventRepo,
args: RegisterWebhookEventArgs,
now: () => number = Date.now,
): Promise<boolean> {
const existing = await repo.findByProviderAndKey({
provider: args.provider,
idempotencyKey: args.idempotencyKey,
});
if (existing) {
return false;
}
await repo.insert({
...args,
createdAt: now(),
});
return true;
}

View File

@@ -1,6 +1,12 @@
import { v } from "convex/values";
import { internalMutation, type MutationCtx } from "./_generated/server";
import {
buildSubscriptionCycleIdempotencyKey,
buildSubscriptionRevokedIdempotencyKey,
buildTopUpPaidIdempotencyKey,
registerWebhookEventOnce,
} from "./polar-utils";
type DbCtx = Pick<MutationCtx, "db">;
@@ -65,7 +71,11 @@ export async function applySubscriptionActivated(ctx: DbCtx, args: ActivatedArgs
});
}
const cycleKey = `polar:subscription_cycle:${args.polarSubscriptionId}:${args.currentPeriodStart}:${args.currentPeriodEnd}`;
const cycleKey = buildSubscriptionCycleIdempotencyKey({
polarSubscriptionId: args.polarSubscriptionId,
currentPeriodStart: args.currentPeriodStart,
currentPeriodEnd: args.currentPeriodEnd,
});
const isFirstCycleEvent = await registerWebhookEvent(ctx, {
provider: "polar",
scope: "subscription_activated_cycle",
@@ -145,9 +155,10 @@ export async function applySubscriptionRevoked(ctx: DbCtx, args: RevokedArgs) {
});
}
const revokedKey = args.polarSubscriptionId
? `polar:subscription_revoked:${args.polarSubscriptionId}`
: `polar:subscription_revoked:user:${args.userId}`;
const revokedKey = buildSubscriptionRevokedIdempotencyKey({
userId: args.userId,
polarSubscriptionId: args.polarSubscriptionId,
});
const isFirstRevokedEvent = await registerWebhookEvent(ctx, {
provider: "polar",
scope: "subscription_revoked",
@@ -175,7 +186,7 @@ export async function applyTopUpPaid(ctx: DbCtx, args: TopUpArgs) {
const isFirstTopUpEvent = await registerWebhookEvent(ctx, {
provider: "polar",
scope: "topup_paid",
idempotencyKey: `polar:order_paid:${args.polarOrderId}`,
idempotencyKey: buildTopUpPaidIdempotencyKey(args.polarOrderId),
userId: args.userId,
polarOrderId: args.polarOrderId,
});
@@ -218,28 +229,42 @@ async function registerWebhookEvent(
polarSubscriptionId?: string;
}
) {
const existing = await ctx.db
.query("webhookIdempotencyEvents")
.withIndex("by_provider_key", (q) =>
q.eq("provider", args.provider).eq("idempotencyKey", args.idempotencyKey)
)
.first();
if (existing) {
return false;
}
await ctx.db.insert("webhookIdempotencyEvents", {
provider: args.provider,
scope: args.scope,
idempotencyKey: args.idempotencyKey,
userId: args.userId,
polarOrderId: args.polarOrderId,
polarSubscriptionId: args.polarSubscriptionId,
createdAt: Date.now(),
});
return true;
return await registerWebhookEventOnce(
{
findByProviderAndKey: async ({ provider, idempotencyKey }) => {
const existing = await ctx.db
.query("webhookIdempotencyEvents")
.withIndex("by_provider_key", (q) =>
q.eq("provider", provider).eq("idempotencyKey", idempotencyKey)
)
.first();
if (!existing) {
return null;
}
return { _id: existing._id };
},
insert: async ({
provider,
scope,
idempotencyKey,
userId,
polarOrderId,
polarSubscriptionId,
createdAt,
}) => {
await ctx.db.insert("webhookIdempotencyEvents", {
provider,
scope,
idempotencyKey,
userId,
polarOrderId,
polarSubscriptionId,
createdAt,
});
},
},
args,
);
}
export const handleSubscriptionActivated = internalMutation({