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 <noreply@anthropic.com>
This commit is contained in:
Garfield
2026-05-14 17:57:29 -04:00
parent 02398258a5
commit d6302a673d
4 changed files with 716 additions and 4 deletions

286
package-lock.json generated
View File

@@ -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",

View File

@@ -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"

View File

@@ -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);
});
}

View File

@@ -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('<form method="POST" action="/login">');
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 &quot; in the hidden field value
expect(res.text).not.toContain('"onmouseover=alert(1)"');
expect(res.text).toContain('&quot;');
});
});
// ── 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('<html>consent</html>');
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);
});
});