From d6302a673de7f4567709ef54902210a7fd70e7de Mon Sep 17 00:00:00 2001 From: Garfield Date: Thu, 14 May 2026 17:57:29 -0400 Subject: [PATCH] test: add OAuth login route test suite (22 cases) Guards the browser OAuth popup flow used by claude.ai and ChatGPT: - GET /login: return_to URL validation, XSS escaping, error display - POST /login: first-party cookie properties (httpOnly/secure/lax/domain), open redirect blocking, credential rejection paths - GET /oauth/authorize: must redirect to /login (never app.squaremcp.com), return_to encoding, valid session bypasses redirect Also exports `app` from index.ts and guards main() with NODE_ENV !== 'test' so the Express app can be imported by supertest without triggering DB init. Co-Authored-By: Claude Sonnet 4.6 --- package-lock.json | 286 ++++++++++++++++++++++ package.json | 2 + src/index.ts | 12 +- src/oauth-login-routes.test.ts | 420 +++++++++++++++++++++++++++++++++ 4 files changed, 716 insertions(+), 4 deletions(-) create mode 100644 src/oauth-login-routes.test.ts diff --git a/package-lock.json b/package-lock.json index 70a9f0d..28ced2d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,10 +28,12 @@ "@types/jsonwebtoken": "^9.0.10", "@types/node": "^20.0.0", "@types/nodemailer": "^6.4.0", + "@types/supertest": "^7.2.0", "@vitest/coverage-v8": "^4.1.6", "pixelmatch": "^7.1.0", "playwright": "^1.59.1", "pngjs": "^7.0.0", + "supertest": "^7.2.2", "tsx": "^4.0.0", "typescript": "^5.0.0", "vitest": "^4.1.6" @@ -957,6 +959,19 @@ "@emnapi/runtime": "^1.7.1" } }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@oxc-project/types": { "version": "0.129.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.129.0.tgz", @@ -967,6 +982,16 @@ "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/@paralleldrive/cuid2": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", + "integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.1.5" + } + }, "node_modules/@pinojs/redact": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", @@ -1376,6 +1401,13 @@ "@types/express": "*" } }, + "node_modules/@types/cookiejar": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz", + "integrity": "sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/cors": { "version": "2.8.19", "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", @@ -1443,6 +1475,13 @@ "@types/node": "*" } }, + "node_modules/@types/methods": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz", + "integrity": "sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/mime": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", @@ -1523,6 +1562,30 @@ "@types/node": "*" } }, + "node_modules/@types/superagent": { + "version": "8.1.9", + "resolved": "https://registry.npmjs.org/@types/superagent/-/superagent-8.1.9.tgz", + "integrity": "sha512-pTVjI73witn+9ILmoJdajHGW2jkSaOzhiFYF1Rd3EQ94kymLqB9PjD9ISg7WaALC7+dCHT0FGe9T2LktLq/3GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/cookiejar": "^2.1.5", + "@types/methods": "^1.1.4", + "@types/node": "*", + "form-data": "^4.0.0" + } + }, + "node_modules/@types/supertest": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@types/supertest/-/supertest-7.2.0.tgz", + "integrity": "sha512-uh2Lv57xvggst6lCqNdFAmDSvoMG7M/HDtX4iUCquxQ5EGPtaPM5PL5Hmi7LCvOG8db7YaCPNJEeoI8s/WzIQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/methods": "^1.1.4", + "@types/superagent": "^8.1.0" + } + }, "node_modules/@vitest/coverage-v8": { "version": "4.1.6", "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.6.tgz", @@ -1730,6 +1793,13 @@ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "license": "MIT" }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "dev": true, + "license": "MIT" + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -1752,6 +1822,13 @@ "js-tokens": "^10.0.0" } }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/atomic-sleep": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", @@ -1881,6 +1958,29 @@ "node": ">=0.10.0" } }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/component-emitter": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", + "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/content-disposition": { "version": "0.5.4", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", @@ -1943,6 +2043,13 @@ "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", "license": "MIT" }, + "node_modules/cookiejar": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", + "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", + "dev": true, + "license": "MIT" + }, "node_modules/cors": { "version": "2.8.6", "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", @@ -1983,6 +2090,16 @@ "ms": "2.0.0" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/denque": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", @@ -2021,6 +2138,17 @@ "node": ">=8" } }, + "node_modules/dezalgo": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", + "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", + "dev": true, + "license": "ISC", + "dependencies": { + "asap": "^2.0.0", + "wrappy": "1" + } + }, "node_modules/dotenv": { "version": "16.6.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", @@ -2117,6 +2245,22 @@ "node": ">= 0.4" } }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/esbuild": { "version": "0.27.3", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", @@ -2285,6 +2429,13 @@ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "license": "MIT" }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "dev": true, + "license": "MIT" + }, "node_modules/fast-uri": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", @@ -2337,6 +2488,41 @@ "node": ">= 0.8" } }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/formidable": { + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz", + "integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@paralleldrive/cuid2": "^2.2.2", + "dezalgo": "^1.0.4", + "once": "^1.4.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "url": "https://ko-fi.com/tunnckoCore/commissions" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -2472,6 +2658,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", @@ -4055,6 +4257,90 @@ "dev": true, "license": "MIT" }, + "node_modules/superagent": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-10.3.0.tgz", + "integrity": "sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "component-emitter": "^1.3.1", + "cookiejar": "^2.1.4", + "debug": "^4.3.7", + "fast-safe-stringify": "^2.1.1", + "form-data": "^4.0.5", + "formidable": "^3.5.4", + "methods": "^1.1.2", + "mime": "2.6.0", + "qs": "^6.14.1" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/superagent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/superagent/node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/superagent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/supertest": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/supertest/-/supertest-7.2.2.tgz", + "integrity": "sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cookie-signature": "^1.2.2", + "methods": "^1.1.2", + "superagent": "^10.3.0" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/supertest/node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", diff --git a/package.json b/package.json index 7b4c324..517b7fa 100644 --- a/package.json +++ b/package.json @@ -38,10 +38,12 @@ "@types/jsonwebtoken": "^9.0.10", "@types/node": "^20.0.0", "@types/nodemailer": "^6.4.0", + "@types/supertest": "^7.2.0", "@vitest/coverage-v8": "^4.1.6", "pixelmatch": "^7.1.0", "playwright": "^1.59.1", "pngjs": "^7.0.0", + "supertest": "^7.2.2", "tsx": "^4.0.0", "typescript": "^5.0.0", "vitest": "^4.1.6" diff --git a/src/index.ts b/src/index.ts index 2232050..62ee8b8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2134,7 +2134,11 @@ async function main() { }); } -main().catch((err) => { - console.error('Failed to start server:', err); - process.exit(1); -}); +export { app }; + +if (process.env.NODE_ENV !== 'test') { + main().catch((err) => { + console.error('Failed to start server:', err); + process.exit(1); + }); +} diff --git a/src/oauth-login-routes.test.ts b/src/oauth-login-routes.test.ts new file mode 100644 index 0000000..c224c2c --- /dev/null +++ b/src/oauth-login-routes.test.ts @@ -0,0 +1,420 @@ +/** + * Tests for the OAuth login flow routes and the /oauth/authorize redirect behavior. + * + * Critical invariants guarded here: + * 1. GET /login serves an HTML form and validates the return_to URL + * 2. POST /login sets a first-party session cookie with correct security properties + * 3. POST /login with bad credentials redirects to /login?error=invalid (no open redirect) + * 4. GET /oauth/authorize unauthenticated → redirects to /login (NOT app.squaremcp.com) + * 5. GET /oauth/authorize with valid session → shows consent page + * + * Why these matter: claude.ai and ChatGPT use browser-based OAuth popups. If the + * authorize endpoint redirects to app.squaremcp.com instead of hermes /login, the + * session cookie is set in the wrong top-level context (browser CHIPS partitioning) + * and the OAuth flow fails silently. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import request from 'supertest'; + +// ── Hoist mocks before any imports ──────────────────────────────────────────── + +const { + mockFindCustomerByEmail, + mockVerifyPassword, + mockSignJWT, + mockVerifyJWT, + mockGetClient, + mockIsValidRedirectUri, + mockGetAuthorizeHtml, + mockInitDatabase, + mockGetPool, + mockRedis, + mockEnsureOAuthAppRegistered, +} = vi.hoisted(() => ({ + mockFindCustomerByEmail: vi.fn(), + mockVerifyPassword: vi.fn(), + mockSignJWT: vi.fn(), + mockVerifyJWT: vi.fn(), + mockGetClient: vi.fn(), + mockIsValidRedirectUri: vi.fn(), + mockGetAuthorizeHtml: vi.fn(), + mockInitDatabase: vi.fn(), + mockGetPool: vi.fn(() => ({ execute: vi.fn(), query: vi.fn() })), + mockRedis: { get: vi.fn(), set: vi.fn(), del: vi.fn(), quit: vi.fn() }, + mockEnsureOAuthAppRegistered: vi.fn(), +})); + +vi.mock('./auth.js', () => ({ + findCustomerByEmail: mockFindCustomerByEmail, + findCustomerById: vi.fn(), + verifyPassword: mockVerifyPassword, + signJWT: mockSignJWT, + verifyJWT: mockVerifyJWT, + hashPassword: vi.fn(), + createCustomer: vi.fn(), + setResetToken: vi.fn(), + findCustomerByResetToken: vi.fn(), + clearResetToken: vi.fn(), + updatePassword: vi.fn(), +})); + +vi.mock('./oauth.js', () => ({ + getClient: mockGetClient, + isValidRedirectUri: mockIsValidRedirectUri, + getAuthorizeHtml: mockGetAuthorizeHtml, + registerClient: vi.fn(), + createAuthCode: vi.fn(), + exchangeCodeForToken: vi.fn(), + validateAccessToken: vi.fn(), + getTokenCustomer: vi.fn(), + ensureOAuthAppRegistered: mockEnsureOAuthAppRegistered, + revokeToken: vi.fn(), +})); + +vi.mock('./db.js', () => ({ + initDatabase: mockInitDatabase, + getPool: mockGetPool, + isPoolReady: vi.fn(() => true), +})); + +vi.mock('./redis.js', () => ({ default: mockRedis })); + +vi.mock('./billing/middleware.js', () => ({ + meterMiddleware: vi.fn((_req: unknown, _res: unknown, next: () => void) => next()), + resolveCustomerByApiKey: vi.fn(), + resolveCustomerById: vi.fn(), +})); + +vi.mock('./billing/usage.js', () => ({ + recordUsage: vi.fn(), + getMonthlyUsage: vi.fn(() => 0), + getUsageBreakdown: vi.fn(() => []), + checkLimit: vi.fn(() => ({ allowed: true, limit: 1000 })), +})); + +vi.mock('./billing/invoices.js', () => ({ + getCustomerInvoices: vi.fn(() => []), + getInvoiceByNumber: vi.fn(), + markInvoiceSent: vi.fn(), + markInvoicePaid: vi.fn(), + generateMonthlyInvoice: vi.fn(), +})); + +vi.mock('./billing/cron.js', () => ({ startInvoiceCron: vi.fn() })); + +vi.mock('./multitenancy/webhook-router.js', () => ({ + routeWhatsAppWebhook: vi.fn(), + registerWhatsAppNumber: vi.fn(), +})); + +vi.mock('./multitenancy/credential-store.js', () => ({ + storeCredential: vi.fn(), + getCredential: vi.fn(), +})); + +vi.mock('./multitenancy/platform-health.js', () => ({ + getAllPlatformHealth: vi.fn(() => []), +})); + +vi.mock('./multitenancy/audit-log.js', () => ({ logAudit: vi.fn() })); + +vi.mock('./webhooks/delivery.js', () => ({ + deliverWebhook: vi.fn(), + isValidWebhookUrl: vi.fn(() => true), +})); + +vi.mock('./tools.js', () => ({ + tools: [], + handleToolCall: vi.fn(), + stripAccountParam: vi.fn((args: unknown) => args), +})); + +vi.mock('./manifest.js', () => ({ + getManifest: vi.fn(() => ({})), + getOpenApiSpec: vi.fn(() => ({})), + getOpenApiSpecMail: vi.fn(() => ({})), + getOpenApiSpecSocial: vi.fn(() => ({})), +})); + +// ── Import app after mocks ───────────────────────────────────────────────────── + +process.env.NODE_ENV = 'test'; +process.env.SERVER_URL = 'https://hermes.squaremcp.com'; +process.env.MCP_API_KEY = 'test-global-key'; + +const { app } = await import('./index.js'); + +// ── Shared fixtures ──────────────────────────────────────────────────────────── + +const HERMES_RETURN_TO = 'https://hermes.squaremcp.com/oauth/authorize?client_id=abc&redirect_uri=https%3A%2F%2Fclaude.ai%2Fapi%2Fmcp%2Fauth_callback&state=xyz'; + +const fakeCustomer = { + id: 'cust-1', + email: 'user@example.com', + plan: 'starter', + role: 'user', + active: true, + api_key: 'api-key-1', + password_hash: '$2b$10$hashedpassword', +}; + +const fakeClient = { + client_id: 'abc', + client_secret: 'secret', + client_name: 'Test Client', + redirect_uris: ['https://claude.ai/api/mcp/auth_callback'], + created_at: Date.now(), +}; + +// ── GET /login ───────────────────────────────────────────────────────────────── + +describe('GET /login', () => { + it('returns 200 HTML with a login form', async () => { + const res = await request(app).get('/login'); + expect(res.status).toBe(200); + expect(res.headers['content-type']).toMatch(/text\/html/); + expect(res.text).toContain('
'); + expect(res.text).toContain('SquareMCP'); + }); + + it('embeds a valid hermes return_to in the form hidden field', async () => { + const res = await request(app).get(`/login?return_to=${encodeURIComponent(HERMES_RETURN_TO)}`); + expect(res.status).toBe(200); + expect(res.text).toContain('https://hermes.squaremcp.com/oauth/authorize'); + }); + + it('ignores return_to pointing to other domains (uses /)', async () => { + const res = await request(app).get('/login?return_to=https%3A%2F%2Fevil.com%2Fsteal'); + expect(res.status).toBe(200); + // Should fall back to / — the malicious URL must NOT appear in the form + expect(res.text).not.toContain('evil.com'); + expect(res.text).toContain('value="/"'); + }); + + it('ignores return_to pointing to app.squaremcp.com (different subdomain)', async () => { + const res = await request(app).get('/login?return_to=https%3A%2F%2Fapp.squaremcp.com%2Fdashboard'); + expect(res.status).toBe(200); + expect(res.text).not.toContain('app.squaremcp.com'); + expect(res.text).toContain('value="/"'); + }); + + it('shows error message for error=invalid', async () => { + const res = await request(app).get('/login?error=invalid'); + expect(res.text).toContain('Incorrect email or password'); + }); + + it('shows error message for error=missing', async () => { + const res = await request(app).get('/login?error=missing'); + expect(res.text).toContain('Email and password are required'); + }); + + it('HTML-escapes double quotes in return_to to prevent attribute injection', async () => { + const xssAttempt = 'https://hermes.squaremcp.com/oauth/authorize?x="onmouseover=alert(1)"'; + const res = await request(app).get(`/login?return_to=${encodeURIComponent(xssAttempt)}`); + // Quotes should be replaced with " in the hidden field value + expect(res.text).not.toContain('"onmouseover=alert(1)"'); + expect(res.text).toContain('"'); + }); +}); + +// ── POST /login ──────────────────────────────────────────────────────────────── + +describe('POST /login', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockSignJWT.mockReturnValue('jwt-token-abc'); + }); + + it('valid credentials → sets session cookie with correct security properties', async () => { + mockFindCustomerByEmail.mockResolvedValue(fakeCustomer); + mockVerifyPassword.mockResolvedValue(true); + + const res = await request(app) + .post('/login') + .type('form') + .send({ email: 'user@example.com', password: 'correct', return_to: HERMES_RETURN_TO }); + + expect(res.status).toBe(302); + + const setCookie = res.headers['set-cookie'] as string[] | string; + const cookieStr = Array.isArray(setCookie) ? setCookie.join('; ') : setCookie; + + expect(cookieStr).toContain('session=jwt-token-abc'); + expect(cookieStr.toLowerCase()).toContain('httponly'); + expect(cookieStr.toLowerCase()).toContain('secure'); + expect(cookieStr.toLowerCase()).toContain('samesite=lax'); + expect(cookieStr.toLowerCase()).toContain('domain=.squaremcp.com'); + }); + + it('valid credentials → redirects to return_to on hermes domain', async () => { + mockFindCustomerByEmail.mockResolvedValue(fakeCustomer); + mockVerifyPassword.mockResolvedValue(true); + + const res = await request(app) + .post('/login') + .type('form') + .send({ email: 'user@example.com', password: 'correct', return_to: HERMES_RETURN_TO }); + + expect(res.status).toBe(302); + expect(res.headers.location).toBe(HERMES_RETURN_TO); + }); + + it('valid credentials + non-hermes return_to → redirects to / (open redirect blocked)', async () => { + mockFindCustomerByEmail.mockResolvedValue(fakeCustomer); + mockVerifyPassword.mockResolvedValue(true); + + const res = await request(app) + .post('/login') + .type('form') + .send({ email: 'user@example.com', password: 'correct', return_to: 'https://evil.com/steal' }); + + expect(res.status).toBe(302); + expect(res.headers.location).toBe('/'); + expect(res.headers.location).not.toContain('evil.com'); + }); + + it('valid credentials + app.squaremcp.com return_to → redirects to / (wrong domain blocked)', async () => { + mockFindCustomerByEmail.mockResolvedValue(fakeCustomer); + mockVerifyPassword.mockResolvedValue(true); + + const res = await request(app) + .post('/login') + .type('form') + .send({ email: 'user@example.com', password: 'correct', return_to: 'https://app.squaremcp.com/dashboard' }); + + expect(res.status).toBe(302); + expect(res.headers.location).toBe('/'); + }); + + it('wrong password → redirect to /login?error=invalid (no cookie set)', async () => { + mockFindCustomerByEmail.mockResolvedValue(fakeCustomer); + mockVerifyPassword.mockResolvedValue(false); + + const res = await request(app) + .post('/login') + .type('form') + .send({ email: 'user@example.com', password: 'wrong', return_to: HERMES_RETURN_TO }); + + expect(res.status).toBe(302); + expect(res.headers.location).toMatch(/\/login.*error=invalid/); + expect(res.headers['set-cookie']).toBeUndefined(); + }); + + it('unknown email → redirect to /login?error=invalid', async () => { + mockFindCustomerByEmail.mockResolvedValue(null); + + const res = await request(app) + .post('/login') + .type('form') + .send({ email: 'nobody@example.com', password: 'anything', return_to: HERMES_RETURN_TO }); + + expect(res.status).toBe(302); + expect(res.headers.location).toMatch(/\/login.*error=invalid/); + }); + + it('missing fields → redirect to /login?error=missing', async () => { + const res = await request(app) + .post('/login') + .type('form') + .send({ return_to: HERMES_RETURN_TO }); + + expect(res.status).toBe(302); + expect(res.headers.location).toMatch(/\/login.*error=missing/); + }); + + it('preserves hermes return_to through failed login redirect', async () => { + mockFindCustomerByEmail.mockResolvedValue(null); + + const res = await request(app) + .post('/login') + .type('form') + .send({ email: 'x@x.com', password: 'bad', return_to: HERMES_RETURN_TO }); + + expect(res.status).toBe(302); + expect(res.headers.location).toContain(encodeURIComponent(HERMES_RETURN_TO)); + }); +}); + +// ── GET /oauth/authorize — redirect behavior ─────────────────────────────────── + +describe('GET /oauth/authorize — unauthenticated redirect', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGetClient.mockResolvedValue(fakeClient); + mockIsValidRedirectUri.mockReturnValue(true); + }); + + it('redirects to /login — NOT to app.squaremcp.com', async () => { + const res = await request(app).get( + '/oauth/authorize?client_id=abc&redirect_uri=https%3A%2F%2Fclaude.ai%2Fapi%2Fmcp%2Fauth_callback&response_type=code&state=xyz' + ); + + expect(res.status).toBe(302); + // Must redirect to /login (first-party on hermes) + expect(res.headers.location).toMatch(/^\/login\?/); + // Must NOT redirect to app.squaremcp.com (wrong cookie context — breaks browser OAuth popup) + expect(res.headers.location).not.toContain('app.squaremcp.com'); + }); + + it('return_to in redirect encodes the full hermes.squaremcp.com authorize URL', async () => { + const res = await request(app).get( + '/oauth/authorize?client_id=abc&redirect_uri=https%3A%2F%2Fclaude.ai%2Fapi%2Fmcp%2Fauth_callback&state=xyz' + ); + + expect(res.status).toBe(302); + const location = res.headers.location as string; + expect(location).toContain('return_to='); + + // The encoded return_to must start with the hermes domain + const returnToEncoded = new URL('http://dummy' + location).searchParams.get('return_to'); + expect(returnToEncoded).toMatch(/^https:\/\/hermes\.squaremcp\.com\//); + expect(returnToEncoded).toContain('client_id=abc'); + }); + + it('with invalid cookie → also redirects to /login (not app.squaremcp.com)', async () => { + mockVerifyJWT.mockImplementation(() => { throw new Error('invalid token'); }); + + const res = await request(app) + .get('/oauth/authorize?client_id=abc&redirect_uri=https%3A%2F%2Fclaude.ai%2Fapi%2Fmcp%2Fauth_callback') + .set('Cookie', 'session=bad-token'); + + expect(res.status).toBe(302); + expect(res.headers.location).toMatch(/^\/login\?/); + expect(res.headers.location).not.toContain('app.squaremcp.com'); + }); + + it('with valid session cookie → returns 200 consent HTML (no redirect)', async () => { + mockVerifyJWT.mockReturnValue({ sub: 'cust-1', email: 'user@example.com', plan: 'starter' }); + mockGetAuthorizeHtml.mockReturnValue('consent'); + + const res = await request(app) + .get('/oauth/authorize?client_id=abc&redirect_uri=https%3A%2F%2Fclaude.ai%2Fapi%2Fmcp%2Fauth_callback') + .set('Cookie', 'session=valid-jwt'); + + expect(res.status).toBe(200); + expect(res.text).toContain('consent'); + expect(res.headers.location).toBeUndefined(); + }); + + it('missing client_id → 400 (not a redirect)', async () => { + const res = await request(app).get('/oauth/authorize?redirect_uri=https%3A%2F%2Fclaude.ai%2Fcallback'); + expect(res.status).toBe(400); + }); + + it('unregistered client_id → 400', async () => { + mockGetClient.mockResolvedValue(null); + const res = await request(app).get( + '/oauth/authorize?client_id=unknown&redirect_uri=https%3A%2F%2Fclaude.ai%2Fcallback' + ); + expect(res.status).toBe(400); + }); + + it('redirect_uri not registered for client → 400', async () => { + mockIsValidRedirectUri.mockReturnValue(false); + const res = await request(app).get( + '/oauth/authorize?client_id=abc&redirect_uri=https%3A%2F%2Fevil.com%2Fcallback' + ); + expect(res.status).toBe(400); + }); +});