diff --git a/package-lock.json b/package-lock.json index aa6ac4a..ec69028 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,6 +30,7 @@ "@types/pg": "^8.18.0", "@types/supertest": "^7.2.0", "@types/swagger-jsdoc": "^6.0.4", + "@vitest/coverage-v8": "^4.0.18", "supertest": "^7.2.2", "terser": "^5.46.0", "tsx": "^4.21.0", @@ -95,6 +96,16 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/helper-validator-identifier": { "version": "7.28.5", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", @@ -104,6 +115,46 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/parser": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.3", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", @@ -1262,6 +1313,37 @@ "@types/node": "*" } }, + "node_modules/@vitest/coverage-v8": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.0.18.tgz", + "integrity": "sha512-7i+N2i0+ME+2JFZhfuz7Tg/FqKtilHjGyGvoHYQ6iLV0zahbsJ9sljC9OcFcPDbhYKCet+sG8SsVqlyGvPflZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.0.18", + "ast-v8-to-istanbul": "^0.3.10", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.1", + "obug": "^2.1.1", + "std-env": "^3.10.0", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.0.18", + "vitest": "4.0.18" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, "node_modules/@vitest/expect": { "version": "4.0.18", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz", @@ -1465,6 +1547,25 @@ "node": ">=4" } }, + "node_modules/ast-v8-to-istanbul": { + "version": "0.3.12", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.12.tgz", + "integrity": "sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -2715,6 +2816,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -2764,6 +2875,13 @@ "node": ">=18.0.0" } }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -2944,6 +3062,45 @@ "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", "license": "MIT" }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -3013,6 +3170,34 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/magicast": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.2.tgz", + "integrity": "sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "source-map-js": "^1.2.1" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/marked": { "version": "17.0.4", "resolved": "https://registry.npmjs.org/marked/-/marked-17.0.4.tgz", @@ -4394,6 +4579,19 @@ "node": ">=14.18.0" } }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/swagger-jsdoc": { "version": "6.2.8", "resolved": "https://registry.npmjs.org/swagger-jsdoc/-/swagger-jsdoc-6.2.8.tgz", diff --git a/package.json b/package.json index d9636e6..7295b16 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "@types/pg": "^8.18.0", "@types/supertest": "^7.2.0", "@types/swagger-jsdoc": "^6.0.4", + "@vitest/coverage-v8": "^4.0.18", "supertest": "^7.2.2", "terser": "^5.46.0", "tsx": "^4.21.0", diff --git a/src/__tests__/billing-error-paths.test.ts b/src/__tests__/billing-error-paths.test.ts new file mode 100644 index 0000000..1e881e4 --- /dev/null +++ b/src/__tests__/billing-error-paths.test.ts @@ -0,0 +1,455 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import request from "supertest"; + +// Mock logger +const mockLogger = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), +}; + +vi.mock("../services/logger.js", () => ({ + default: mockLogger, +})); + +// Mock keys service +const mockCreateProKey = vi.fn(); +const mockDowngradeByCustomer = vi.fn(); +const mockUpdateEmailByCustomer = vi.fn(); +const mockFindKeyByCustomerId = vi.fn(); + +vi.mock("../services/keys.js", () => ({ + createProKey: mockCreateProKey, + downgradeByCustomer: mockDowngradeByCustomer, + updateEmailByCustomer: mockUpdateEmailByCustomer, + findKeyByCustomerId: mockFindKeyByCustomerId, +})); + +// Mock billing templates +const mockRenderSuccessPage = vi.fn(); +const mockRenderAlreadyProvisionedPage = vi.fn(); + +vi.mock("../utils/billing-templates.js", () => ({ + renderSuccessPage: mockRenderSuccessPage, + renderAlreadyProvisionedPage: mockRenderAlreadyProvisionedPage, +})); + +// Mock Stripe +const mockStripe = { + checkout: { + sessions: { + create: vi.fn(), + retrieve: vi.fn(), + listLineItems: vi.fn(), + }, + }, + customers: { + retrieve: vi.fn(), + update: vi.fn(), + }, + subscriptions: { + retrieve: vi.fn(), + }, + webhooks: { + constructEvent: vi.fn(), + }, + products: { + list: vi.fn(), + }, + prices: { + list: vi.fn(), + create: vi.fn(), + }, +}; + +vi.mock("stripe", () => ({ + default: vi.fn().mockImplementation(() => mockStripe), +})); + +describe("Billing Error Paths", () => { + let app: any; + let billingRouter: any; + + beforeEach(async () => { + vi.clearAllMocks(); + + // Set up environment + process.env.STRIPE_SECRET_KEY = "sk_test_123"; + process.env.STRIPE_WEBHOOK_SECRET = "whsec_test"; + + // Mock default responses + mockRenderSuccessPage.mockReturnValue("Success"); + mockRenderAlreadyProvisionedPage.mockReturnValue("Already Provisioned"); + + // Import and set up minimal Express app + const express = await import("express"); + const billingModule = await import("../routes/billing.js"); + + app = express.default(); + app.use(express.default.json()); + app.use(express.default.raw({ type: "application/json" })); + app.use("/", billingModule.default); + + billingRouter = billingModule.default; + }); + + afterEach(() => { + delete process.env.STRIPE_SECRET_KEY; + delete process.env.STRIPE_WEBHOOK_SECRET; + }); + + describe("getStripe function error handling", () => { + it("should throw error when STRIPE_SECRET_KEY is not configured", async () => { + delete process.env.STRIPE_SECRET_KEY; + + // Clear the module cache to get a fresh import + vi.resetModules(); + + // Re-import the billing module + const express = await import("express"); + + const testApp = express.default(); + testApp.use(express.default.json()); + + // Import billing routes (this should work despite missing env var) + const billingModule = await import("../routes/billing.js"); + testApp.use("/", billingModule.default); + + // Making a checkout request should trigger the getStripe() function and fail + const response = await request(testApp) + .post("/checkout") + .send({ email: "test@example.com" }); + + expect(response.status).toBe(500); + expect(mockLogger.error).toHaveBeenCalledWith( + expect.objectContaining({ + err: expect.objectContaining({ + message: "STRIPE_SECRET_KEY not configured", + }), + }), + "Checkout error" + ); + }); + }); + + describe("checkout error handling", () => { + beforeEach(() => { + process.env.STRIPE_SECRET_KEY = "sk_test_123"; + }); + + it("should handle Stripe session creation failure", async () => { + const stripeError = new Error("Stripe down"); + stripeError.code = "api_connection_error"; + mockStripe.checkout.sessions.create.mockRejectedValueOnce(stripeError); + + const response = await request(app) + .post("/checkout") + .send({ email: "test@example.com" }); + + expect(response.status).toBe(500); + expect(mockLogger.error).toHaveBeenCalledWith( + expect.objectContaining({ + err: expect.objectContaining({ + message: "Stripe down", + }), + }), + "Checkout error" + ); + }); + + it("should handle product/price retrieval failure during checkout", async () => { + const stripeError = new Error("Product not found"); + stripeError.code = "resource_missing"; + mockStripe.products.list.mockRejectedValueOnce(stripeError); + + const response = await request(app) + .post("/checkout") + .send({ email: "test@example.com" }); + + expect(response.status).toBe(500); + expect(mockLogger.error).toHaveBeenCalledWith( + expect.objectContaining({ + err: expect.objectContaining({ + message: "Product not found", + }), + }), + "Checkout error" + ); + }); + }); + + describe("success page error handling", () => { + it("should handle missing session_id parameter", async () => { + const response = await request(app) + .get("/success"); + + expect(response.status).toBe(400); + expect(response.text).toBe("Missing session_id"); + }); + + it("should handle Stripe session retrieval failure", async () => { + const stripeError = new Error("Session expired"); + stripeError.code = "resource_missing"; + mockStripe.checkout.sessions.retrieve.mockRejectedValueOnce(stripeError); + + const response = await request(app) + .get("/success?session_id=cs_test_invalid"); + + expect(response.status).toBe(500); + expect(mockLogger.error).toHaveBeenCalledWith( + expect.objectContaining({ + err: expect.objectContaining({ + message: "Session expired", + }), + }), + "Success page error" + ); + }); + + it("should handle line items retrieval failure", async () => { + mockStripe.checkout.sessions.retrieve.mockResolvedValueOnce({ + id: "cs_test_123", + status: "complete", + customer: "cus_test", + customer_email: "test@example.com", + }); + + const stripeError = new Error("Stripe retrieve failed"); + stripeError.code = "api_error"; + mockStripe.checkout.sessions.listLineItems.mockRejectedValueOnce(stripeError); + + const response = await request(app) + .get("/success?session_id=cs_test_123"); + + expect(response.status).toBe(500); + expect(mockLogger.error).toHaveBeenCalledWith( + expect.objectContaining({ + err: expect.objectContaining({ + message: "Stripe retrieve failed", + }), + sessionId: "cs_test_123", + }), + "Failed to retrieve session line_items" + ); + }); + }); + + describe("webhook error handling", () => { + it("should reject webhooks without signature header", async () => { + const response = await request(app) + .post("/webhook") + .send({ type: "checkout.session.completed" }); + + expect(response.status).toBe(400); + expect(response.text).toBe("Missing stripe-signature header"); + }); + + it("should reject webhooks when STRIPE_WEBHOOK_SECRET is not configured", async () => { + delete process.env.STRIPE_WEBHOOK_SECRET; + + const response = await request(app) + .post("/webhook") + .set("stripe-signature", "t=123,v1=abc") + .send({ type: "checkout.session.completed" }); + + expect(response.status).toBe(400); + expect(response.text).toBe("Webhook secret not configured"); + expect(mockLogger.error).toHaveBeenCalledWith( + "STRIPE_WEBHOOK_SECRET is not configured — refusing to process unverified webhooks" + ); + }); + + it("should handle invalid webhook signature", async () => { + const signatureError = new Error("Invalid signature"); + signatureError.code = "signature_verification_failed"; + mockStripe.webhooks.constructEvent.mockImplementation(() => { + throw signatureError; + }); + + const response = await request(app) + .post("/webhook") + .set("stripe-signature", "invalid_signature") + .send(JSON.stringify({ type: "checkout.session.completed" })); + + expect(response.status).toBe(400); + expect(response.text).toBe("Invalid signature"); + expect(mockLogger.error).toHaveBeenCalledWith( + expect.objectContaining({ + err: expect.objectContaining({ + message: "Invalid signature", + }), + }), + "Webhook signature verification failed" + ); + }); + + it("should handle checkout.session.completed with missing data gracefully", async () => { + const webhookEvent = { + type: "checkout.session.completed", + data: { + object: { + id: "cs_test_123", + customer: null, // Missing customer + customer_email: null, // Missing email + }, + }, + }; + + mockStripe.webhooks.constructEvent.mockReturnValueOnce(webhookEvent); + + const response = await request(app) + .post("/webhook") + .set("stripe-signature", "valid_signature") + .send(JSON.stringify(webhookEvent)); + + expect(response.status).toBe(200); + expect(mockLogger.warn).toHaveBeenCalledWith( + "checkout.session.completed: missing customerId or email, skipping key provisioning" + ); + }); + + it("should handle subscription retrieval failure during webhook processing", async () => { + const webhookEvent = { + type: "customer.subscription.updated", + data: { + object: { + id: "sub_test_123", + customer: "cus_test_123", + status: "active", + cancel_at_period_end: false, + items: { + data: [{ price: { product: "prod_test" } }], + }, + }, + }, + }; + + mockStripe.webhooks.constructEvent.mockReturnValueOnce(webhookEvent); + + const stripeError = new Error("Subscription not found"); + stripeError.code = "resource_missing"; + mockStripe.subscriptions.retrieve.mockRejectedValueOnce(stripeError); + + const response = await request(app) + .post("/webhook") + .set("stripe-signature", "valid_signature") + .send(JSON.stringify(webhookEvent)); + + expect(response.status).toBe(500); + expect(mockLogger.error).toHaveBeenCalledWith( + expect.objectContaining({ + err: expect.objectContaining({ + message: "Subscription not found", + }), + }), + "Error processing webhook" + ); + }); + }); + + describe("session cleanup functionality", () => { + it("should clean up old provisioned sessions", () => { + vi.useFakeTimers(); + + // Set a fixed time + const fixedTime = new Date("2022-01-02T12:00:00Z").getTime(); + vi.setSystemTime(fixedTime); + + // Manually trigger the cleanup logic by advancing time + // The cleanup interval is 1 hour, so advance by more than that + vi.advanceTimersByTime(60 * 60 * 1000 + 1000); // 1 hour + 1 second + + // The cleanup function should log when it runs + expect(mockLogger.info).toHaveBeenCalledWith( + expect.objectContaining({ + cleanedCount: expect.any(Number), + remainingCount: expect.any(Number), + }), + "Cleaned up expired provisioned sessions" + ); + + vi.useRealTimers(); + }); + }); + + describe("edge cases in webhook event processing", () => { + it("should handle customer email update for missing customer", async () => { + const webhookEvent = { + type: "customer.updated", + data: { + object: { + id: "cus_nonexistent", + email: "newemail@test.com", + }, + }, + }; + + mockStripe.webhooks.constructEvent.mockReturnValueOnce(webhookEvent); + mockFindKeyByCustomerId.mockResolvedValueOnce(null); // Customer not found + + const response = await request(app) + .post("/webhook") + .set("stripe-signature", "valid_signature") + .send(JSON.stringify(webhookEvent)); + + expect(response.status).toBe(200); + // Should not attempt to update email if customer key is not found + expect(mockUpdateEmailByCustomer).not.toHaveBeenCalled(); + }); + + it("should handle database errors during key provisioning", async () => { + const webhookEvent = { + type: "checkout.session.completed", + data: { + object: { + id: "cs_test_123", + customer: "cus_test_123", + customer_email: "test@example.com", + }, + }, + }; + + mockStripe.webhooks.constructEvent.mockReturnValueOnce(webhookEvent); + + const dbError = new Error("Database connection failed"); + mockCreateProKey.mockRejectedValueOnce(dbError); + + const response = await request(app) + .post("/webhook") + .set("stripe-signature", "valid_signature") + .send(JSON.stringify(webhookEvent)); + + expect(response.status).toBe(500); + expect(mockLogger.error).toHaveBeenCalledWith( + expect.objectContaining({ + err: expect.objectContaining({ + message: "Database connection failed", + }), + }), + "Error processing webhook" + ); + }); + + it("should handle unknown webhook event types gracefully", async () => { + const webhookEvent = { + type: "unknown.event.type", + data: { + object: { + id: "obj_123", + }, + }, + }; + + mockStripe.webhooks.constructEvent.mockReturnValueOnce(webhookEvent); + + const response = await request(app) + .post("/webhook") + .set("stripe-signature", "valid_signature") + .send(JSON.stringify(webhookEvent)); + + expect(response.status).toBe(200); + // Should not crash or log errors for unknown event types + expect(mockLogger.error).not.toHaveBeenCalled(); + }); + }); +}); \ No newline at end of file diff --git a/src/__tests__/browser-error-handling.test.ts b/src/__tests__/browser-error-handling.test.ts new file mode 100644 index 0000000..9f30c2d --- /dev/null +++ b/src/__tests__/browser-error-handling.test.ts @@ -0,0 +1,316 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +// Don't use the global mock — we test the real browser service +vi.unmock("../services/browser.js"); + +// Mock logger +vi.mock("../services/logger.js", () => ({ + default: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +function createMockPage() { + const page: any = { + setJavaScriptEnabled: vi.fn().mockResolvedValue(undefined), + setContent: vi.fn().mockResolvedValue(undefined), + addStyleTag: vi.fn().mockResolvedValue(undefined), + pdf: vi.fn().mockResolvedValue(Buffer.from("%PDF-1.4 test")), + goto: vi.fn().mockResolvedValue(undefined), + close: vi.fn().mockResolvedValue(undefined), + setRequestInterception: vi.fn().mockResolvedValue(undefined), + removeAllListeners: vi.fn().mockReturnThis(), + createCDPSession: vi.fn().mockResolvedValue({ + send: vi.fn().mockResolvedValue(undefined), + detach: vi.fn().mockResolvedValue(undefined), + }), + cookies: vi.fn().mockResolvedValue([{ name: "test", value: "cookie" }]), + deleteCookie: vi.fn().mockResolvedValue(undefined), + on: vi.fn(), + continue: vi.fn().mockResolvedValue(undefined), + abort: vi.fn().mockResolvedValue(undefined), + }; + return page; +} + +function createMockBrowser(pagesPerBrowser = 2) { + const pages = Array.from({ length: pagesPerBrowser }, () => createMockPage()); + let pageIndex = 0; + const browser: any = { + newPage: vi.fn().mockImplementation(() => Promise.resolve(pages[pageIndex++] || createMockPage())), + close: vi.fn().mockResolvedValue(undefined), + _pages: pages, + }; + return browser; +} + +// Set env vars before importing +process.env.BROWSER_COUNT = "1"; +process.env.PAGES_PER_BROWSER = "1"; + +let mockBrowsers: any[] = []; +let launchCallCount = 0; + +// Mock puppeteer +vi.mock("puppeteer", () => { + return { + default: { + launch: vi.fn().mockImplementation(() => { + const browser = createMockBrowser(1); + mockBrowsers.push(browser); + launchCallCount++; + return Promise.resolve(browser); + }), + }, + }; +}); + +describe("Browser Service Error Handling", () => { + let browserService: any; + + beforeEach(async () => { + vi.clearAllMocks(); + mockBrowsers = []; + launchCallCount = 0; + + // Dynamic import to ensure mocks are set up + browserService = await import("../services/browser.js"); + await browserService.initBrowser(); + }); + + afterEach(async () => { + await browserService.closeBrowser(); + }); + + describe("recyclePage error handling", () => { + it("should handle CDP session creation failure gracefully", async () => { + const failingPage = createMockPage(); + failingPage.createCDPSession = vi.fn().mockRejectedValue(new Error("CDP failed")); + + // Should not throw + await expect(browserService.recyclePage(failingPage)).resolves.toBeUndefined(); + }); + + it("should handle CDP detach failure gracefully", async () => { + const failingPage = createMockPage(); + failingPage.createCDPSession = vi.fn().mockResolvedValue({ + send: vi.fn().mockResolvedValue(undefined), + detach: vi.fn().mockRejectedValue(new Error("Detach failed")), + }); + + // Should not throw + await expect(browserService.recyclePage(failingPage)).resolves.toBeUndefined(); + }); + + it("should handle setRequestInterception failure gracefully", async () => { + const failingPage = createMockPage(); + failingPage.setRequestInterception = vi.fn().mockRejectedValue(new Error("Request interception failed")); + + // Should not throw + await expect(browserService.recyclePage(failingPage)).resolves.toBeUndefined(); + }); + + it("should handle goto failure gracefully", async () => { + const failingPage = createMockPage(); + failingPage.goto = vi.fn().mockRejectedValue(new Error("Goto failed")); + + // Should not throw + await expect(browserService.recyclePage(failingPage)).resolves.toBeUndefined(); + }); + + it("should handle deleteCookie failure gracefully", async () => { + const failingPage = createMockPage(); + failingPage.deleteCookie = vi.fn().mockRejectedValue(new Error("Delete cookie failed")); + + // Should not throw + await expect(browserService.recyclePage(failingPage)).resolves.toBeUndefined(); + }); + }); + + describe("queue timeout handling", () => { + it("should timeout and reject with QUEUE_FULL when all pages are busy", async () => { + // Simulate all pages being busy by not releasing any + const result1 = browserService.renderPdf("Test 1"); + const result2 = browserService.renderPdf("Test 2"); + + // This third request should queue and eventually timeout + const timeoutPromise = browserService.renderPdf("Test 3"); + + // Fast-forward time to trigger timeout + vi.useFakeTimers(); + const startTime = Date.now(); + + await expect(async () => { + vi.advanceTimersByTime(30_000); // Advance by 30 seconds to trigger timeout + await timeoutPromise; + }).rejects.toThrow("QUEUE_FULL"); + + vi.useRealTimers(); + + // Clean up the running renders + await result1; + await result2; + }, 35000); + }); + + describe("renderUrlPdf with hostResolverRules error handling", () => { + it("should handle request interception errors gracefully", async () => { + const mockPage = createMockPage(); + mockPage.goto = vi.fn().mockResolvedValue(undefined); + mockPage.pdf = vi.fn().mockResolvedValue(Buffer.from("%PDF-1.4 test")); + mockPage.setRequestInterception = vi.fn().mockResolvedValue(undefined); + + // Mock the on method to simulate request handling + let requestHandler: any; + mockPage.on = vi.fn().mockImplementation((event: string, handler: any) => { + if (event === "request") { + requestHandler = handler; + } + }); + + const mockRequest = { + url: () => "http://example.com/test", + headers: () => ({}), + continue: vi.fn().mockRejectedValue(new Error("Continue failed")), + abort: vi.fn().mockResolvedValue(undefined), + }; + + // Replace the mock browser's newPage to return our special mock page + const mockBrowser = mockBrowsers[0]; + mockBrowser.newPage = vi.fn().mockResolvedValue(mockPage); + + const result = await browserService.renderUrlPdf("http://example.com/test", { + hostResolverRules: "MAP example.com 192.168.1.1", + }); + + expect(result.pdf).toBeInstanceOf(Buffer); + expect(mockPage.setRequestInterception).toHaveBeenCalledWith(true); + }); + + it("should block requests to non-target hosts", async () => { + const mockPage = createMockPage(); + mockPage.goto = vi.fn().mockResolvedValue(undefined); + mockPage.pdf = vi.fn().mockResolvedValue(Buffer.from("%PDF-1.4 test")); + + let requestHandler: any; + mockPage.on = vi.fn().mockImplementation((event: string, handler: any) => { + if (event === "request") { + requestHandler = handler; + } + }); + + const mockRequest = { + url: () => "http://malicious.com/evil", + abort: vi.fn().mockResolvedValue(undefined), + }; + + // Replace the mock browser's newPage + const mockBrowser = mockBrowsers[0]; + mockBrowser.newPage = vi.fn().mockResolvedValue(mockPage); + + await browserService.renderUrlPdf("http://example.com/test", { + hostResolverRules: "MAP example.com 192.168.1.1", + }); + + // Simulate a request to a different host + if (requestHandler) { + requestHandler(mockRequest); + expect(mockRequest.abort).toHaveBeenCalledWith("blockedbyclient"); + } + }); + }); + + describe("PDF generation timeout", () => { + it("should timeout during PDF generation when page hangs", async () => { + vi.useFakeTimers(); + + const mockPage = createMockPage(); + // Make setContent hang forever + mockPage.setContent = vi.fn().mockImplementation(() => new Promise(() => {})); + + const mockBrowser = mockBrowsers[0]; + mockBrowser.newPage = vi.fn().mockResolvedValue(mockPage); + + const renderPromise = browserService.renderPdf("Hanging test"); + + // Fast-forward past the timeout + vi.advanceTimersByTime(30_000); + + await expect(renderPromise).rejects.toThrow("PDF_TIMEOUT"); + + vi.useRealTimers(); + }); + + it("should timeout during URL navigation when page hangs", async () => { + vi.useFakeTimers(); + + const mockPage = createMockPage(); + // Make goto hang forever + mockPage.goto = vi.fn().mockImplementation(() => new Promise(() => {})); + + const mockBrowser = mockBrowsers[0]; + mockBrowser.newPage = vi.fn().mockResolvedValue(mockPage); + + const renderPromise = browserService.renderUrlPdf("http://example.com/hanging"); + + // Fast-forward past the timeout + vi.advanceTimersByTime(30_000); + + await expect(renderPromise).rejects.toThrow("PDF_TIMEOUT"); + + vi.useRealTimers(); + }); + }); + + describe("buildPdfOptions edge cases", () => { + it("should handle all optional parameters being set", () => { + const options = { + format: "Legal" as const, + landscape: true, + margin: { top: "1in", right: "0.5in", bottom: "1in", left: "0.5in" }, + printBackground: false, + headerTemplate: "
Header
", + footerTemplate: "
Footer
", + displayHeaderFooter: true, + scale: 0.8, + pageRanges: "1-3", + preferCSSPageSize: true, + width: "8.5in", + height: "11in", + }; + + const result = browserService.buildPdfOptions(options); + + expect(result).toEqual({ + format: "Legal", + landscape: true, + margin: { top: "1in", right: "0.5in", bottom: "1in", left: "0.5in" }, + printBackground: false, + headerTemplate: "
Header
", + footerTemplate: "
Footer
", + displayHeaderFooter: true, + scale: 0.8, + pageRanges: "1-3", + preferCSSPageSize: true, + width: "8.5in", + height: "11in", + }); + }); + + it("should handle empty string values correctly", () => { + const options = { + headerTemplate: "", + footerTemplate: "", + pageRanges: "", + width: "", + height: "", + }; + + const result = browserService.buildPdfOptions(options); + + expect(result.headerTemplate).toBe(""); + expect(result.footerTemplate).toBe(""); + expect(result.pageRanges).toBe(""); + expect(result.width).toBe(""); + expect(result.height).toBe(""); + }); + }); +}); \ No newline at end of file diff --git a/src/__tests__/db-retry-logic.test.ts b/src/__tests__/db-retry-logic.test.ts new file mode 100644 index 0000000..6a1dd82 --- /dev/null +++ b/src/__tests__/db-retry-logic.test.ts @@ -0,0 +1,335 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +// Mock logger +vi.mock("../services/logger.js", () => ({ + default: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +// Mock pg +const mockQuery = vi.fn(); +const mockConnect = vi.fn(); +const mockRelease = vi.fn(); +const mockClient = { + query: mockQuery, + release: mockRelease, +}; + +const mockPool = { + connect: mockConnect, + on: vi.fn(), +}; + +vi.mock("pg", () => ({ + Pool: vi.fn().mockImplementation(() => mockPool), +})); + +// Mock error utilities +vi.mock("../utils/errors.js", () => ({ + isTransientError: vi.fn(), + errorMessage: vi.fn(), + errorCode: vi.fn(), +})); + +describe("Database Retry Logic", () => { + let dbService: any; + let isTransientError: any; + let errorMessage: any; + let errorCode: any; + + beforeEach(async () => { + vi.clearAllMocks(); + mockQuery.mockReset(); + mockConnect.mockReset(); + mockRelease.mockReset(); + + // Get the mocked functions + const errorUtils = await import("../utils/errors.js"); + isTransientError = errorUtils.isTransientError; + errorMessage = errorUtils.errorMessage; + errorCode = errorUtils.errorCode; + + // Setup default mocks + isTransientError.mockImplementation((err: any) => { + return err.code === "ENOTFOUND" || err.code === "ECONNRESET" || err.code === "ETIMEDOUT"; + }); + errorMessage.mockImplementation((err: any) => err.message || "Unknown error"); + errorCode.mockImplementation((err: any) => err.code || "UNKNOWN"); + + mockConnect.mockResolvedValue(mockClient); + mockQuery.mockResolvedValue({ rows: [], rowCount: 0 }); + + // Import the service after mocks are set up + dbService = await import("../services/db.js"); + }); + + describe("queryWithRetry", () => { + it("should succeed on first attempt for successful queries", async () => { + const expectedResult = { rows: [{ id: 1, name: "test" }], rowCount: 1 }; + mockQuery.mockResolvedValueOnce(expectedResult); + + const result = await dbService.queryWithRetry("SELECT * FROM test", []); + + expect(result).toEqual(expectedResult); + expect(mockConnect).toHaveBeenCalledTimes(1); + expect(mockQuery).toHaveBeenCalledTimes(1); + expect(mockRelease).toHaveBeenCalledWith(); // Normal release + }); + + it("should retry on transient errors and eventually succeed", async () => { + vi.useFakeTimers(); + + const transientError = new Error("Connection lost"); + transientError.code = "ECONNRESET"; + const expectedResult = { rows: [{ id: 1 }], rowCount: 1 }; + + // First two calls fail, third succeeds + mockQuery + .mockRejectedValueOnce(transientError) + .mockRejectedValueOnce(transientError) + .mockResolvedValueOnce(expectedResult); + + const queryPromise = dbService.queryWithRetry("SELECT * FROM test", [], 3); + + // Advance timers to handle retry delays + vi.advanceTimersByTime(1000); // First retry delay + vi.advanceTimersByTime(2000); // Second retry delay + + const result = await queryPromise; + + expect(result).toEqual(expectedResult); + expect(mockConnect).toHaveBeenCalledTimes(3); + expect(mockQuery).toHaveBeenCalledTimes(3); + // Should have called release(true) for failed attempts to destroy bad connections + expect(mockRelease).toHaveBeenCalledWith(true); + expect(mockRelease).toHaveBeenCalledWith(); // Final successful release + + vi.useRealTimers(); + }); + + it("should fail immediately on non-transient errors", async () => { + const nonTransientError = new Error("Syntax error"); + nonTransientError.code = "42601"; // PostgreSQL syntax error + isTransientError.mockReturnValue(false); + + mockQuery.mockRejectedValueOnce(nonTransientError); + + await expect(dbService.queryWithRetry("INVALID SQL", [])).rejects.toThrow("Syntax error"); + + expect(mockConnect).toHaveBeenCalledTimes(1); + expect(mockQuery).toHaveBeenCalledTimes(1); + expect(mockRelease).toHaveBeenCalledWith(true); // Should destroy the connection even for non-transient errors + }); + + it("should fail after exhausting all retries", async () => { + vi.useFakeTimers(); + + const transientError = new Error("Network timeout"); + transientError.code = "ETIMEDOUT"; + + mockQuery.mockRejectedValue(transientError); + + const queryPromise = dbService.queryWithRetry("SELECT * FROM test", [], 2); + + // Advance timers for all retry attempts + vi.advanceTimersByTime(1000); // First retry + vi.advanceTimersByTime(2000); // Second retry + + await expect(queryPromise).rejects.toThrow("Network timeout"); + + expect(mockConnect).toHaveBeenCalledTimes(3); // Initial + 2 retries + expect(mockQuery).toHaveBeenCalledTimes(3); + + vi.useRealTimers(); + }); + + it("should handle client connect failures", async () => { + const connectError = new Error("Connection refused"); + connectError.code = "ECONNREFUSED"; + + mockConnect.mockRejectedValueOnce(connectError); + + await expect(dbService.queryWithRetry("SELECT 1", [])).rejects.toThrow("Connection refused"); + + expect(mockConnect).toHaveBeenCalledTimes(1); + expect(mockQuery).not.toHaveBeenCalled(); + }); + + it("should handle client release failures gracefully", async () => { + const expectedResult = { rows: [], rowCount: 0 }; + mockQuery.mockResolvedValueOnce(expectedResult); + mockRelease.mockImplementation(() => { + throw new Error("Release failed"); + }); + + // Should not throw despite release failure + const result = await dbService.queryWithRetry("SELECT 1", []); + + expect(result).toEqual(expectedResult); + }); + }); + + describe("connectWithRetry", () => { + it("should succeed on first attempt for healthy connections", async () => { + mockQuery.mockResolvedValueOnce({ rows: [{ "?column?": 1 }], rowCount: 1 }); + + const client = await dbService.connectWithRetry(); + + expect(client).toBe(mockClient); + expect(mockConnect).toHaveBeenCalledTimes(1); + expect(mockQuery).toHaveBeenCalledWith("SELECT 1"); // Validation query + }); + + it("should retry when connection validation fails", async () => { + vi.useFakeTimers(); + + const validationError = new Error("Connection timeout"); + validationError.code = "ETIMEDOUT"; + + // First connect succeeds, but validation fails + // Second connect and validation succeed + mockQuery + .mockRejectedValueOnce(validationError) + .mockResolvedValueOnce({ rows: [{ "?column?": 1 }], rowCount: 1 }); + + const connectPromise = dbService.connectWithRetry(2); + + // Advance timer for retry delay + vi.advanceTimersByTime(1000); + + const client = await connectPromise; + + expect(client).toBe(mockClient); + expect(mockConnect).toHaveBeenCalledTimes(2); + expect(mockQuery).toHaveBeenCalledTimes(2); + expect(mockRelease).toHaveBeenCalledWith(true); // First connection destroyed due to validation failure + + vi.useRealTimers(); + }); + + it("should fail immediately on non-transient connect errors", async () => { + const nonTransientError = new Error("Authentication failed"); + nonTransientError.code = "28P01"; // PostgreSQL auth error + isTransientError.mockReturnValue(false); + + mockConnect.mockRejectedValueOnce(nonTransientError); + + await expect(dbService.connectWithRetry()).rejects.toThrow("Authentication failed"); + + expect(mockConnect).toHaveBeenCalledTimes(1); + }); + + it("should fail after exhausting connect retries", async () => { + vi.useFakeTimers(); + + const transientError = new Error("Connection timeout"); + transientError.code = "ETIMEDOUT"; + + mockConnect.mockRejectedValue(transientError); + + const connectPromise = dbService.connectWithRetry(2); + + // Advance timers for retry delays + vi.advanceTimersByTime(1000); // First retry + vi.advanceTimersByTime(2000); // Second retry + + await expect(connectPromise).rejects.toThrow("Connection timeout"); + + expect(mockConnect).toHaveBeenCalledTimes(3); // Initial + 2 retries + + vi.useRealTimers(); + }); + + it("should fail on non-transient validation errors", async () => { + const validationError = new Error("Permission denied"); + validationError.code = "42501"; // PostgreSQL permission error + isTransientError.mockReturnValue(false); + + mockQuery.mockRejectedValueOnce(validationError); + + await expect(dbService.connectWithRetry()).rejects.toThrow("Permission denied"); + + expect(mockConnect).toHaveBeenCalledTimes(1); + expect(mockRelease).toHaveBeenCalledWith(true); // Connection destroyed due to validation failure + }); + + it("should handle release failure during validation error cleanup", async () => { + const validationError = new Error("Validation failed"); + validationError.code = "ETIMEDOUT"; + + mockQuery.mockRejectedValueOnce(validationError); + mockRelease.mockImplementation(() => { + throw new Error("Release failed"); + }); + + // Should still throw the validation error, not the release error + await expect(dbService.connectWithRetry(1)).rejects.toThrow("Validation failed"); + }); + }); + + describe("cleanupStaleData", () => { + it("should clean up expired verifications and orphaned usage", async () => { + // Mock the cleanup queries + mockQuery + .mockResolvedValueOnce({ rowCount: 3 }) // expired verifications + .mockResolvedValueOnce({ rowCount: 7 }); // orphaned usage + + const result = await dbService.cleanupStaleData(); + + expect(result).toEqual({ + expiredVerifications: 3, + orphanedUsage: 7, + }); + + expect(mockQuery).toHaveBeenCalledWith( + "DELETE FROM pending_verifications WHERE expires_at < NOW() RETURNING email" + ); + expect(mockQuery).toHaveBeenCalledWith( + expect.stringContaining("DELETE FROM usage") + ); + }); + + it("should handle null rowCount gracefully", async () => { + // Mock queries returning null rowCount + mockQuery + .mockResolvedValueOnce({ rowCount: null }) + .mockResolvedValueOnce({ rowCount: null }); + + const result = await dbService.cleanupStaleData(); + + expect(result).toEqual({ + expiredVerifications: 0, + orphanedUsage: 0, + }); + }); + + it("should handle database errors during cleanup", async () => { + const dbError = new Error("Table does not exist"); + mockQuery.mockRejectedValueOnce(dbError); + + await expect(dbService.cleanupStaleData()).rejects.toThrow("Table does not exist"); + }); + }); + + describe("initDatabase", () => { + it("should create all required tables", async () => { + mockQuery.mockResolvedValueOnce({ rows: [], rowCount: 0 }); + + await dbService.initDatabase(); + + expect(mockConnect).toHaveBeenCalledTimes(1); + expect(mockQuery).toHaveBeenCalledWith( + expect.stringContaining("CREATE TABLE IF NOT EXISTS api_keys") + ); + expect(mockRelease).toHaveBeenCalledTimes(1); + }); + + it("should handle table creation failures", async () => { + const createError = new Error("Permission denied to create table"); + mockQuery.mockRejectedValueOnce(createError); + + await expect(dbService.initDatabase()).rejects.toThrow("Permission denied to create table"); + + expect(mockRelease).toHaveBeenCalledTimes(1); // Should still release the client + }); + }); +}); \ No newline at end of file diff --git a/vitest.config.ts b/vitest.config.ts index b5d47a3..82c0e20 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -9,5 +9,11 @@ export default defineConfig({ ...configDefaults.exclude, "**/dist/**", ], + coverage: { + provider: 'v8', + reporter: ['text', 'text-summary'], + include: ['src/**/*.ts'], + exclude: ['src/__tests__/**', 'src/**/*.test.ts'], + }, }, });