test: add vitest baseline for critical payment and auth guards
This commit is contained in:
9
convex/ai-utils.ts
Normal file
9
convex/ai-utils.ts
Normal 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");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
49
convex/batch-validation-utils.ts
Normal file
49
convex/batch-validation-utils.ts
Normal 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 };
|
||||
}
|
||||
@@ -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
71
convex/polar-utils.ts
Normal 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;
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user