diff --git a/app/actions/supabase/credits/grant-re-engage-credits.test.ts b/app/actions/supabase/credits/grant-re-engage-credits.test.ts index b8f1476a..563b7354 100644 --- a/app/actions/supabase/credits/grant-re-engage-credits.test.ts +++ b/app/actions/supabase/credits/grant-re-engage-credits.test.ts @@ -1,8 +1,12 @@ import { FREE_CREDITS_AMOUNT_USD } from "@/config/pricing"; import { grantReEngageCredits } from "./grant-re-engage-credits"; -import { insertCredits } from "./insert-credits"; +import { insertCredits } from "@/app/actions/supabase/credits/insert-credits"; -jest.mock("./insert-credits"); +jest.mock("@/lib/supabase/server", () => ({ + supabaseAdmin: { from: jest.fn() }, +})); + +jest.mock("@/app/actions/supabase/credits/insert-credits"); const mockInsertCredits = jest.mocked(insertCredits); beforeEach(() => jest.clearAllMocks()); diff --git a/app/actions/supabase/credits/insert-credits.test.ts b/app/actions/supabase/credits/insert-credits.test.ts new file mode 100644 index 00000000..0c2a171c --- /dev/null +++ b/app/actions/supabase/credits/insert-credits.test.ts @@ -0,0 +1,159 @@ + +import { insertCredits } from "./insert-credits"; +import { supabaseAdmin } from "@/lib/supabase/server"; +import { getServerSession } from "next-auth/next"; + +jest.mock("@/lib/supabase/server", () => ({ + supabaseAdmin: { + from: jest.fn(), + }, +})); + +jest.mock("next-auth/next", () => ({ + getServerSession: jest.fn(), +})); + +const mockFrom = supabaseAdmin.from as jest.Mock; +const mockGetServerSession = getServerSession as jest.Mock; + +describe("insertCredits solitary", () => { + beforeEach(() => { + jest.clearAllMocks(); + // Default to authenticated session + mockGetServerSession.mockResolvedValue({ user: { id: "test-user" } }); + }); + + it("should successfully insert credits", async () => { + // Verify happy path: authenticated user can insert credits + const mockInsert = jest.fn().mockResolvedValue({ data: {}, error: null }); + mockFrom.mockReturnValue({ + insert: mockInsert, + }); + + const data = { + owner_id: 123, + amount_usd: 50, + transaction_type: "purchase" as const, + }; + + await expect(insertCredits(data)).resolves.not.toThrow(); + expect(mockGetServerSession).toHaveBeenCalled(); + expect(mockFrom).toHaveBeenCalledWith("credits"); + expect(mockInsert).toHaveBeenCalledWith(data); + }); + + it("should throw error when user is not authenticated", async () => { + // Verify security: unauthenticated users cannot insert credits (auth_bypass) + mockGetServerSession.mockResolvedValue(null); + + const data = { + owner_id: 123, + amount_usd: 50, + transaction_type: "purchase" as const, + }; + + await expect(insertCredits(data)).rejects.toThrow("Unauthorized"); + expect(mockFrom).not.toHaveBeenCalled(); + }); + + it("should throw error when supabase insert fails", async () => { + // Verify that Supabase errors are caught and re-thrown with a descriptive message + const mockError = { message: "Database constraint violation" }; + const mockInsert = jest.fn().mockResolvedValue({ data: null, error: mockError }); + mockFrom.mockReturnValue({ + insert: mockInsert, + }); + + const data = { + owner_id: 123, + amount_usd: 50, + transaction_type: "purchase" as const, + }; + + await expect(insertCredits(data)).rejects.toThrow("Failed to insert credits: Database constraint violation"); + expect(mockFrom).toHaveBeenCalledWith("credits"); + expect(mockInsert).toHaveBeenCalledWith(data); + }); + + describe("combinatorial and boundary tests", () => { + const testCases = [ + { type: "purchase" as const, amount: 0, description: "zero amount purchase" }, + { type: "purchase" as const, amount: 0.01, description: "minimum positive amount purchase" }, + { type: "purchase" as const, amount: 0.000001, description: "extreme precision positive amount" }, + { type: "purchase" as const, amount: 1000000, description: "large amount purchase" }, + { type: "usage" as const, amount: -0.01, description: "minimum negative amount usage" }, + { type: "usage" as const, amount: -0.000001, description: "extreme precision negative amount" }, + { type: "usage" as const, amount: -1000000, description: "large negative amount usage" }, + { type: "auto_reload" as const, amount: 25.50, description: "decimal amount auto-reload" }, + { type: "auto_reload" as const, amount: 0, description: "zero amount auto-reload" }, + ]; + + testCases.forEach(({ type, amount, description }) => { + it(`should handle ${description}`, async () => { + // Verify that various transaction types and amount ranges are handled correctly + const mockInsert = jest.fn().mockResolvedValue({ data: {}, error: null }); + mockFrom.mockReturnValue({ + insert: mockInsert, + }); + + const data = { + owner_id: 123, + amount_usd: amount, + transaction_type: type, + }; + + await expect(insertCredits(data)).resolves.not.toThrow(); + expect(mockInsert).toHaveBeenCalledWith(data); + }); + }); + }); + + describe("adversarial inputs", () => { + it("should throw error when owner_id is null", async () => { + // Verify that null owner_id triggers a database error and is handled correctly + const mockError = { message: "null value in column 'owner_id' violates not-null constraint" }; + const mockInsert = jest.fn().mockResolvedValue({ data: null, error: mockError }); + mockFrom.mockReturnValue({ + insert: mockInsert, + }); + + await expect(insertCredits({ owner_id: null as any, amount_usd: 10, transaction_type: "purchase" as const })) + .rejects.toThrow("Failed to insert credits: null value in column 'owner_id' violates not-null constraint"); + }); + + it("should throw error when amount_usd is undefined", async () => { + // Verify that undefined amount_usd triggers a database error and is handled correctly + const mockError = { message: "null value in column 'amount_usd' violates not-null constraint" }; + const mockInsert = jest.fn().mockResolvedValue({ data: null, error: mockError }); + mockFrom.mockReturnValue({ + insert: mockInsert, + }); + + await expect(insertCredits({ owner_id: 123, amount_usd: undefined as any, transaction_type: "purchase" as const })) + .rejects.toThrow("Failed to insert credits: null value in column 'amount_usd' violates not-null constraint"); + }); + + it("should handle empty data object", async () => { + // Verify behavior with empty data (though types should prevent this, we test for robustness) + const mockInsert = jest.fn().mockResolvedValue({ data: {}, error: null }); + mockFrom.mockReturnValue({ + insert: mockInsert, + }); + + await expect(insertCredits({} as any)).resolves.not.toThrow(); + expect(mockInsert).toHaveBeenCalledWith({}); + }); + }); + + it("should throw error when supabase returns a generic error", async () => { + // Verify that any error returned by Supabase triggers the exception with the correct format + const mockError = { message: "Unexpected error" }; + const mockInsert = jest.fn().mockResolvedValue({ data: null, error: mockError }); + mockFrom.mockReturnValue({ + insert: mockInsert, + }); + + await expect(insertCredits({ owner_id: 123, amount_usd: 10, transaction_type: "purchase" as const })) + .rejects.toThrow("Failed to insert credits: Unexpected error"); + }); +}); diff --git a/app/actions/supabase/credits/insert-credits.ts b/app/actions/supabase/credits/insert-credits.ts index 12abb7a3..b95f205c 100644 --- a/app/actions/supabase/credits/insert-credits.ts +++ b/app/actions/supabase/credits/insert-credits.ts @@ -2,11 +2,19 @@ import type { Database } from "@/types/supabase"; import { supabaseAdmin } from "@/lib/supabase/server"; +import { getServerSession } from "next-auth/next"; type CreditInsert = Database["public"]["Tables"]["credits"]["Insert"]; -export async function insertCredits(data: CreditInsert) { +export async function insertCreditsInternal(data: CreditInsert) { const { error } = await supabaseAdmin.from("credits").insert(data); if (error) throw new Error(`Failed to insert credits: ${error.message}`); } + +export async function insertCredits(data: CreditInsert) { + const session = await getServerSession(); + if (!session) throw new Error("Unauthorized"); + + return insertCreditsInternal(data); +} diff --git a/app/api/stripe/webhook/route.ts b/app/api/stripe/webhook/route.ts index 96f46a0a..19e8527f 100644 --- a/app/api/stripe/webhook/route.ts +++ b/app/api/stripe/webhook/route.ts @@ -4,7 +4,7 @@ import Stripe from "stripe"; // Local imports import { slackUs } from "@/app/actions/slack/slack-us"; -import { insertCredits } from "@/app/actions/supabase/credits/insert-credits"; +import { insertCreditsInternal } from "@/app/actions/supabase/credits/insert-credits"; import { STRIPE_WEBHOOK_SECRET } from "@/config"; import stripe from "@/lib/stripe"; @@ -58,7 +58,7 @@ export async function POST(req: NextRequest) { return NextResponse.json({ received: true }, { status: 200 }); } - await insertCredits({ + await insertCreditsInternal({ owner_id: ownerId, amount_usd: creditAmountUsd, transaction_type: metadata.auto_reload === "true" ? "auto_reload" : "purchase", diff --git a/e2e/credit-workflow/auto-reload.spec.ts b/e2e/credit-workflow/auto-reload.spec.ts index 91acbedd..483b1f0f 100644 --- a/e2e/credit-workflow/auto-reload.spec.ts +++ b/e2e/credit-workflow/auto-reload.spec.ts @@ -2,7 +2,7 @@ import { test, expect } from "@playwright/test"; import fs from "fs/promises"; import path from "path"; import { supabaseAdmin } from "@/lib/supabase/server"; -import { insertCredits } from "@/app/actions/supabase/credits/insert-credits"; +import { insertCreditsInternal } from "@/app/actions/supabase/credits/insert-credits"; import { cleanupTestOwner, createTestOwner } from "../helpers/create-test-owner"; import { triggerStripeWebhook } from "../helpers/stripe-trigger"; import stripe from "@/lib/stripe"; @@ -169,7 +169,7 @@ test.describe("Auto-reload workflow", () => { // Insert negative credit to simulate usage and bring balance to $5 // Current: $100, Target: $5, so we need to subtract $95 - await insertCredits({ + await insertCreditsInternal({ owner_id: testOwnerId, amount_usd: -95, // Negative amount to reduce balance transaction_type: "usage", @@ -292,7 +292,7 @@ test.describe("Auto-reload workflow", () => { const amountToSubtract = currentBalance - targetBalance; if (amountToSubtract > 0) { - await insertCredits({ + await insertCreditsInternal({ owner_id: disabledTestOwnerId, amount_usd: -amountToSubtract, transaction_type: "usage", @@ -412,7 +412,7 @@ test.describe("Auto-reload workflow", () => { const adjustment = targetBalance - currentBalance; if (adjustment !== 0) { - await insertCredits({ + await insertCreditsInternal({ owner_id: thresholdTestOwnerId, amount_usd: adjustment, transaction_type: adjustment > 0 ? "purchase" : "usage", @@ -539,7 +539,7 @@ test.describe("Auto-reload workflow", () => { const amountToSubtract = currentBalance - targetBalance; if (amountToSubtract > 0) { - await insertCredits({ + await insertCreditsInternal({ owner_id: newTestOwnerId, amount_usd: -amountToSubtract, transaction_type: "usage", diff --git a/e2e/helpers/create-test-owner.ts b/e2e/helpers/create-test-owner.ts index 0e983356..53a3b205 100644 --- a/e2e/helpers/create-test-owner.ts +++ b/e2e/helpers/create-test-owner.ts @@ -1,5 +1,5 @@ import { supabaseAdmin } from "@/lib/supabase/server"; -import { insertCredits } from "@/app/actions/supabase/credits/insert-credits"; +import { insertCreditsInternal } from "@/app/actions/supabase/credits/insert-credits"; import { createTestCustomer } from "./create-test-customer"; import stripe from "@/lib/stripe"; @@ -52,7 +52,7 @@ export async function createTestOwner(options: TestOwnerOptions = {}): Promise 0) { - await insertCredits({ - owner_id: testOwnerId, - amount_usd: options.initialCredits, - transaction_type: "purchase", - expires_at: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString(), - }); + if (installationError) { + throw new Error(`Failed to upsert installation: ${installationError.message}`); } - // Verify owner was created + // Verify owner was created before inserting credits const { data: verifyOwner } = await supabaseAdmin .from("owners") .select("owner_id") @@ -94,6 +90,16 @@ export async function createTestOwner(options: TestOwnerOptions = {}): Promise 0) { + await insertCreditsInternal({ + owner_id: testOwnerId, + amount_usd: options.initialCredits, + transaction_type: "purchase", + expires_at: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString(), + }); + } + // Wait for database transaction to be visible to other connections await new Promise((resolve) => setTimeout(resolve, 500)); diff --git a/e2e/setup/auth.setup.ts b/e2e/setup/auth.setup.ts index e1d2659d..045c2286 100644 --- a/e2e/setup/auth.setup.ts +++ b/e2e/setup/auth.setup.ts @@ -1,7 +1,7 @@ import fs from "fs/promises"; import path from "path"; import { test as setup } from "@playwright/test"; -import { insertCredits } from "@/app/actions/supabase/credits/insert-credits"; +import { insertCreditsInternal } from "@/app/actions/supabase/credits/insert-credits"; import { supabaseAdmin } from "@/lib/supabase/server"; import { createTestCustomer } from "../helpers/create-test-customer"; @@ -71,7 +71,7 @@ setup.beforeAll(async () => { } // Add initial credits - await insertCredits({ + await insertCreditsInternal({ owner_id: userId, amount_usd: 100, transaction_type: "purchase", diff --git a/jest.setup.ts b/jest.setup.ts index 8d2ab581..734715f0 100644 --- a/jest.setup.ts +++ b/jest.setup.ts @@ -13,6 +13,10 @@ global.fetch = require("node-fetch"); // Import jest-dom AFTER environment setup because it extends Jest's expect global // which is only available in setupFilesAfterEnv, not setupFiles import "@testing-library/jest-dom"; +jest.mock("next-auth/next", () => ({ + getServerSession: jest.fn().mockResolvedValue({ user: { id: "test-user" } }), +})); + // Suppress console output during tests to keep test output clean global.console = {