diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 857ff94..91b5248 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -13,7 +13,8 @@ "Bash(pnpm vitest run:*)", "Bash(npx tsc:*)", "Bash(pnpm db:generate:*)", - "Bash(DATABASE_URL=\"postgresql://fedjens@localhost:5432/eyrun_test\" JWT_SECRET=\"test-secret-that-is-at-least-32-characters-long\" pnpm db:migrate:*)" + "Bash(DATABASE_URL=\"postgresql://fedjens@localhost:5432/eyrun_test\" JWT_SECRET=\"test-secret-that-is-at-least-32-characters-long\" pnpm db:migrate:*)", + "Bash(ls:*)" ] } } diff --git a/.env.example b/.env.example index 993955f..59af9d8 100644 --- a/.env.example +++ b/.env.example @@ -2,3 +2,4 @@ DATABASE_URL=postgresql://user:password@localhost:5432/eyrun JWT_SECRET=change-me-to-a-random-string-at-least-32-chars PORT=3000 HOST=0.0.0.0 +RESEND_API_KEY=your-resend-api-key-here \ No newline at end of file diff --git a/package.json b/package.json index b9d314a..923fdcc 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "fastify-plugin": "^5.1.0", "jsonwebtoken": "^9.0.3", "postgres": "^3.4.8", + "resend": "^6.9.1", "zod": "^4.3.6" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3e7175d..fd87b71 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,6 +29,9 @@ importers: postgres: specifier: ^3.4.8 version: 3.4.8 + resend: + specifier: ^6.9.1 + version: 6.9.1 zod: specifier: ^4.3.6 version: 4.3.6 @@ -692,6 +695,12 @@ packages: cpu: [x64] os: [win32] + '@selderee/plugin-htmlparser2@0.11.0': + resolution: {integrity: sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==} + + '@stablelib/base64@1.0.1': + resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==} + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -751,6 +760,9 @@ packages: '@vitest/utils@4.0.18': resolution: {integrity: sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==} + '@zone-eu/mailsplit@5.4.8': + resolution: {integrity: sha512-eEyACj4JZ7sjzRvy26QhLgKEMWwQbsw1+QZnlLX+/gihcNH07lVPOcnwf5U6UAL7gkc//J3jVd76o/WS+taUiA==} + abstract-logging@2.0.1: resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==} @@ -802,10 +814,27 @@ packages: supports-color: optional: true + deepmerge@4.3.1: + resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} + engines: {node: '>=0.10.0'} + dequal@2.0.3: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} + dom-serializer@2.0.0: + resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} + + domelementtype@2.3.0: + resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} + + domhandler@5.0.3: + resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} + engines: {node: '>= 4'} + + domutils@3.2.2: + resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} + dotenv@17.2.4: resolution: {integrity: sha512-mudtfb4zRB4bVvdj0xRo+e6duH1csJRM8IukBqfTRvHotn9+LBXB8ynAidP9zHqoRC/fsllXgk4kCKlR21fIhw==} engines: {node: '>=12'} @@ -909,6 +938,14 @@ packages: ecdsa-sig-formatter@1.0.11: resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + encoding-japanese@2.2.0: + resolution: {integrity: sha512-EuJWwlHPZ1LbADuKTClvHtwbaFn4rOD+dRAbWysqEOXRc2Uui0hJInNJrsdH0c+OhJA4nrCBdSkW4DD5YxAo6A==} + engines: {node: '>=8.10.0'} + + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + es-module-lexer@1.7.0: resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} @@ -951,6 +988,9 @@ packages: fast-querystring@1.1.2: resolution: {integrity: sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==} + fast-sha256@1.3.0: + resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==} + fast-uri@3.1.0: resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} @@ -988,9 +1028,28 @@ packages: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} + he@1.2.0: + resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} + hasBin: true + html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + html-to-text@9.0.5: + resolution: {integrity: sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg==} + engines: {node: '>=14'} + + htmlparser2@8.0.2: + resolution: {integrity: sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==} + + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + + iconv-lite@0.7.0: + resolution: {integrity: sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==} + engines: {node: '>=0.10.0'} + ipaddr.js@2.3.0: resolution: {integrity: sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==} engines: {node: '>= 10'} @@ -1030,9 +1089,24 @@ packages: jws@4.0.1: resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} + leac@0.6.0: + resolution: {integrity: sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg==} + + libbase64@1.3.0: + resolution: {integrity: sha512-GgOXd0Eo6phYgh0DJtjQ2tO8dc0IVINtZJeARPeiIJqge+HdsWSuaDTe8ztQ7j/cONByDZ3zeB325AHiv5O0dg==} + + libmime@5.3.7: + resolution: {integrity: sha512-FlDb3Wtha8P01kTL3P9M+ZDNDWPKPmKHWaU/cG/lg5pfuAwdflVpZE+wm9m7pKmC5ww6s+zTxBKS1p6yl3KpSw==} + + libqp@2.1.1: + resolution: {integrity: sha512-0Wd+GPz1O134cP62YU2GTOPNA7Qgl09XwCqM5zpBv87ERCXdfDtyKXvV7c9U22yWJh44QZqBocFnXN11K96qow==} + light-my-request@6.6.0: resolution: {integrity: sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==} + linkify-it@5.0.0: + resolution: {integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==} + lodash.includes@4.3.0: resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} @@ -1060,6 +1134,9 @@ packages: magicast@0.5.2: resolution: {integrity: sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==} + mailparser@3.9.1: + resolution: {integrity: sha512-6vHZcco3fWsDMkf4Vz9iAfxvwrKNGbHx0dV1RKVphQ/zaNY34Buc7D37LSa09jeSeybWzYcTPjhiZFxzVRJedA==} + make-dir@4.0.0: resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} engines: {node: '>=10'} @@ -1072,6 +1149,10 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + nodemailer@7.0.11: + resolution: {integrity: sha512-gnXhNRE0FNhD7wPSCGhdNh46Hs6nm+uTyg+Kq0cZukNQiYdnCsoQjodNP9BQVG9XrcK/v6/MgpAPBUFyzh9pvw==} + engines: {node: '>=6.0.0'} + obug@2.1.1: resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} @@ -1082,9 +1163,15 @@ packages: openapi-types@12.1.3: resolution: {integrity: sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==} + parseley@0.12.1: + resolution: {integrity: sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw==} + pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + peberminta@0.9.0: + resolution: {integrity: sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -1116,6 +1203,10 @@ packages: process-warning@5.0.0: resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==} + punycode.js@2.3.1: + resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==} + engines: {node: '>=6'} + quick-format-unescaped@4.0.4: resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} @@ -1127,6 +1218,15 @@ packages: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} + resend@6.9.1: + resolution: {integrity: sha512-jFY3qPP2cith1npRXvS7PVdnhbR1CcuzHg65ty5Elv55GKiXhe+nItXuzzoOlKeYJez1iJAo2+8f6ae8sCj0iA==} + engines: {node: '>=20'} + peerDependencies: + '@react-email/render': '*' + peerDependenciesMeta: + '@react-email/render': + optional: true + resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} @@ -1156,9 +1256,15 @@ packages: resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} engines: {node: '>=10'} + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + secure-json-parse@4.1.0: resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==} + selderee@0.11.0: + resolution: {integrity: sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA==} + semver@7.7.4: resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} engines: {node: '>=10'} @@ -1191,6 +1297,9 @@ packages: stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + standardwebhooks@1.0.0: + resolution: {integrity: sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==} + std-env@3.10.0: resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} @@ -1198,6 +1307,9 @@ packages: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} + svix@1.84.1: + resolution: {integrity: sha512-K8DPPSZaW/XqXiz1kEyzSHYgmGLnhB43nQCMeKjWGCUpLIpAMMM8kx3rVVOSm6Bo6EHyK1RQLPT4R06skM/MlQ==} + thread-stream@4.0.0: resolution: {integrity: sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==} engines: {node: '>=20'} @@ -1217,6 +1329,10 @@ packages: resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==} engines: {node: '>=14.0.0'} + tlds@1.261.0: + resolution: {integrity: sha512-QXqwfEl9ddlGBaRFXIvNKK6OhipSiLXuRuLJX5DErz0o0Q0rYxulWLdFryTkV5PkdZct5iMInwYEGe/eR++1AA==} + hasBin: true + toad-cache@3.7.0: resolution: {integrity: sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw==} engines: {node: '>=12'} @@ -1231,9 +1347,16 @@ packages: engines: {node: '>=14.17'} hasBin: true + uc.micro@2.1.0: + resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==} + undici-types@7.16.0: resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + uuid@10.0.0: + resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==} + hasBin: true + vite@7.3.1: resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1691,6 +1814,13 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.57.1': optional: true + '@selderee/plugin-htmlparser2@0.11.0': + dependencies: + domhandler: 5.0.3 + selderee: 0.11.0 + + '@stablelib/base64@1.0.1': {} + '@standard-schema/spec@1.1.0': {} '@types/chai@5.2.3': @@ -1766,6 +1896,12 @@ snapshots: '@vitest/pretty-format': 4.0.18 tinyrainbow: 3.0.3 + '@zone-eu/mailsplit@5.4.8': + dependencies: + libbase64: 1.3.0 + libmime: 5.3.7 + libqp: 2.1.1 + abstract-logging@2.0.1: {} ajv-formats@3.0.1(ajv@8.17.1): @@ -1806,8 +1942,28 @@ snapshots: dependencies: ms: 2.1.3 + deepmerge@4.3.1: {} + dequal@2.0.3: {} + dom-serializer@2.0.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + entities: 4.5.0 + + domelementtype@2.3.0: {} + + domhandler@5.0.3: + dependencies: + domelementtype: 2.3.0 + + domutils@3.2.2: + dependencies: + dom-serializer: 2.0.0 + domelementtype: 2.3.0 + domhandler: 5.0.3 + dotenv@17.2.4: {} drizzle-kit@0.31.8: @@ -1827,6 +1983,10 @@ snapshots: dependencies: safe-buffer: 5.2.1 + encoding-japanese@2.2.0: {} + + entities@4.5.0: {} + es-module-lexer@1.7.0: {} esbuild-register@3.6.0(esbuild@0.25.12): @@ -1942,6 +2102,8 @@ snapshots: dependencies: fast-decode-uri-component: 1.0.1 + fast-sha256@1.3.0: {} + fast-uri@3.1.0: {} fastify-plugin@5.1.0: {} @@ -1987,8 +2149,33 @@ snapshots: has-flag@4.0.0: {} + he@1.2.0: {} + html-escaper@2.0.2: {} + html-to-text@9.0.5: + dependencies: + '@selderee/plugin-htmlparser2': 0.11.0 + deepmerge: 4.3.1 + dom-serializer: 2.0.0 + htmlparser2: 8.0.2 + selderee: 0.11.0 + + htmlparser2@8.0.2: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.2.2 + entities: 4.5.0 + + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + + iconv-lite@0.7.0: + dependencies: + safer-buffer: 2.1.2 + ipaddr.js@2.3.0: {} istanbul-lib-coverage@3.2.2: {} @@ -2044,12 +2231,29 @@ snapshots: jwa: 2.0.1 safe-buffer: 5.2.1 + leac@0.6.0: {} + + libbase64@1.3.0: {} + + libmime@5.3.7: + dependencies: + encoding-japanese: 2.2.0 + iconv-lite: 0.6.3 + libbase64: 1.3.0 + libqp: 2.1.1 + + libqp@2.1.1: {} + light-my-request@6.6.0: dependencies: cookie: 1.1.1 process-warning: 4.0.1 set-cookie-parser: 2.7.2 + linkify-it@5.0.0: + dependencies: + uc.micro: 2.1.0 + lodash.includes@4.3.0: {} lodash.isboolean@3.0.3: {} @@ -2074,6 +2278,19 @@ snapshots: '@babel/types': 7.29.0 source-map-js: 1.2.1 + mailparser@3.9.1: + dependencies: + '@zone-eu/mailsplit': 5.4.8 + encoding-japanese: 2.2.0 + he: 1.2.0 + html-to-text: 9.0.5 + iconv-lite: 0.7.0 + libmime: 5.3.7 + linkify-it: 5.0.0 + nodemailer: 7.0.11 + punycode.js: 2.3.1 + tlds: 1.261.0 + make-dir@4.0.0: dependencies: semver: 7.7.4 @@ -2082,14 +2299,23 @@ snapshots: nanoid@3.3.11: {} + nodemailer@7.0.11: {} + obug@2.1.1: {} on-exit-leak-free@2.1.2: {} openapi-types@12.1.3: {} + parseley@0.12.1: + dependencies: + leac: 0.6.0 + peberminta: 0.9.0 + pathe@2.0.3: {} + peberminta@0.9.0: {} + picocolors@1.1.1: {} picomatch@4.0.3: {} @@ -2126,12 +2352,19 @@ snapshots: process-warning@5.0.0: {} + punycode.js@2.3.1: {} + quick-format-unescaped@4.0.4: {} real-require@0.2.0: {} require-from-string@2.0.2: {} + resend@6.9.1: + dependencies: + mailparser: 3.9.1 + svix: 1.84.1 + resolve-pkg-maps@1.0.0: {} ret@0.5.0: {} @@ -2179,8 +2412,14 @@ snapshots: safe-stable-stringify@2.5.0: {} + safer-buffer@2.1.2: {} + secure-json-parse@4.1.0: {} + selderee@0.11.0: + dependencies: + parseley: 0.12.1 + semver@7.7.4: {} set-cookie-parser@2.7.2: {} @@ -2204,12 +2443,22 @@ snapshots: stackback@0.0.2: {} + standardwebhooks@1.0.0: + dependencies: + '@stablelib/base64': 1.0.1 + fast-sha256: 1.3.0 + std-env@3.10.0: {} supports-color@7.2.0: dependencies: has-flag: 4.0.0 + svix@1.84.1: + dependencies: + standardwebhooks: 1.0.0 + uuid: 10.0.0 + thread-stream@4.0.0: dependencies: real-require: 0.2.0 @@ -2225,6 +2474,8 @@ snapshots: tinyrainbow@3.0.3: {} + tlds@1.261.0: {} + toad-cache@3.7.0: {} tsx@4.21.0: @@ -2236,8 +2487,12 @@ snapshots: typescript@5.9.3: {} + uc.micro@2.1.0: {} + undici-types@7.16.0: {} + uuid@10.0.0: {} + vite@7.3.1(@types/node@25.2.1)(tsx@4.21.0)(yaml@2.8.2): dependencies: esbuild: 0.27.3 diff --git a/src/config.ts b/src/config.ts index 39fedef..3ac9b58 100644 --- a/src/config.ts +++ b/src/config.ts @@ -7,6 +7,7 @@ const envSchema = z.object({ PORT: z.coerce.number().default(3000), HOST: z.string().default("0.0.0.0"), API_URL: z.url().optional(), + RESEND_API_KEY: z.string().min(1), }); const parsed = envSchema.safeParse(process.env); diff --git a/src/lib/otp.ts b/src/lib/otp.ts index 29d991d..bf6ac4b 100644 --- a/src/lib/otp.ts +++ b/src/lib/otp.ts @@ -1,8 +1,12 @@ import crypto from "node:crypto"; import { eq, and, gt, lt, sql } from "drizzle-orm"; +import { Resend } from "resend"; +import { config } from "../config.js"; import { db } from "../db/index.js"; import { otpCodes } from "../db/schema.js"; +const resend = new Resend(config.RESEND_API_KEY); + const OTP_MAX_ATTEMPTS = 5; const OTP_TTL_MINUTES = 10; const OTP_RATE_LIMIT_PER_HOUR = 3; @@ -23,11 +27,20 @@ export async function requestOtp(email: string): Promise { const expiresAt = new Date(Date.now() + OTP_TTL_MINUTES * 60 * 1000); await db.insert(otpCodes).values({ email, code, expiresAt }); - sendOtp(email, code); + await sendOtp(email, code); } -function sendOtp(email: string, code: string): void { - console.log(`[OTP] Code for ${email}: ${code}`); +async function sendOtp(email: string, code: string): Promise { + const { error } = await resend.emails.send({ + from: "Eyrun ", + to: email, + subject: "Your verification code", + html: `

Your verification code is: ${code}

This code expires in 10 minutes.

`, + }); + + if (error) { + throw new Error(`Failed to send OTP email: ${error.message}`); + } } /** Validate an OTP code for the given email. Throws on failure. */ diff --git a/src/plugins/account-context.ts b/src/plugins/account-context.ts index dace4e0..a0cf831 100644 --- a/src/plugins/account-context.ts +++ b/src/plugins/account-context.ts @@ -19,9 +19,9 @@ async function accountContextPlugin(app: FastifyInstance) { app.decorateRequest("membership", null as unknown as { role: string }); app.decorate("requireAccount", async (request: FastifyRequest, reply: FastifyReply) => { - const accountId = request.headers["x-account-id"]; + const accountId = (request.params as Record).accountId; if (!accountId || typeof accountId !== "string") { - return reply.status(400).send({ error: "X-Account-Id header is required" }); + return reply.status(400).send({ error: "accountId path parameter is required" }); } const [membership] = await db diff --git a/src/routes/index.ts b/src/routes/index.ts index 71a3e87..1b0a047 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -13,10 +13,10 @@ export async function registerRoutes(app: FastifyInstance) { app.register(healthRoutes); app.register(loginRoutes); app.register(signupRoutes); - app.register(sessionRoutes, { prefix: "/sessions" }); + app.register(sessionRoutes); app.register(meRoutes); app.register(accountRoutes); - app.register(projectRoutes); + app.register(projectRoutes, { prefix: "/accounts/:accountId" }); app.register(packageRoutes); - app.register(wmRoutes); + app.register(wmRoutes, { prefix: "/accounts/:accountId" }); } diff --git a/src/routes/login.ts b/src/routes/login.ts index 66153d3..89f8e08 100644 --- a/src/routes/login.ts +++ b/src/routes/login.ts @@ -1,13 +1,18 @@ import type { FastifyInstance } from "fastify"; -import { requestOtp, OtpRateLimitError } from "../lib/otp.js"; +import { eq } from "drizzle-orm"; +import { db } from "../db/index.js"; +import { users } from "../db/schema.js"; +import { requestOtp, OtpRateLimitError, verifyOtp, OtpError } from "../lib/otp.js"; +import { createSession } from "../lib/sessions.js"; +import { getUserAccounts } from "../lib/accounts.js"; export async function loginRoutes(app: FastifyInstance) { - // POST /login — send OTP to email + // POST /login — send OTP to existing user app.post<{ Body: { email: string } }>( "/login", { schema: { - description: "Request an OTP (One-Time Password) for login", + description: "Request an OTP for an existing user", tags: ["Authentication"], body: { type: "object", @@ -24,6 +29,13 @@ export async function loginRoutes(app: FastifyInstance) { message: { type: "string" }, }, }, + 404: { + description: "User not found", + type: "object", + properties: { + error: { type: "string" }, + }, + }, 429: { description: "Too many OTP requests", type: "object", @@ -37,6 +49,11 @@ export async function loginRoutes(app: FastifyInstance) { async (request, reply) => { const { email } = request.body; + const [user] = await db.select().from(users).where(eq(users.email, email)); + if (!user) { + return reply.status(404).send({ error: "No account found. Please sign up." }); + } + try { await requestOtp(email); } catch (err) { @@ -49,4 +66,88 @@ export async function loginRoutes(app: FastifyInstance) { return { message: "OTP sent to your email" }; }, ); + + // POST /login/verify — verify OTP, create session for existing user + app.post<{ Body: { email: string; code: string } }>( + "/login/verify", + { + schema: { + description: "Verify OTP and create a session for an existing user", + tags: ["Authentication"], + body: { + type: "object", + required: ["email", "code"], + properties: { + email: { type: "string", format: "email" }, + code: { type: "string", minLength: 6, maxLength: 6 }, + }, + }, + response: { + 200: { + description: "Session created successfully", + type: "object", + properties: { + accessToken: { type: "string" }, + refreshToken: { type: "string" }, + user: { + type: "object", + properties: { + id: { type: "string" }, + email: { type: "string" }, + name: { type: "string" }, + }, + }, + accounts: { + type: "array", + items: { + type: "object", + properties: { + id: { type: "string" }, + name: { type: "string" }, + role: { type: "string" }, + }, + }, + }, + }, + }, + 400: { + description: "Invalid or expired OTP", + type: "object", + properties: { + error: { type: "string" }, + }, + }, + 401: { + description: "User not found", + type: "object", + properties: { + error: { type: "string" }, + }, + }, + }, + }, + }, + async (request, reply) => { + const { email, code } = request.body; + + try { + await verifyOtp(email, code); + } catch (err) { + if (err instanceof OtpError) { + return reply.status(400).send({ error: err.message }); + } + throw err; + } + + const [user] = await db.select().from(users).where(eq(users.email, email)); + if (!user) { + return reply.status(401).send({ error: "No account found. Please sign up." }); + } + + const tokens = await createSession(user.id); + const userAccounts = await getUserAccounts(user.id); + + return { ...tokens, user, accounts: userAccounts }; + }, + ); } diff --git a/src/routes/projects.ts b/src/routes/projects.ts index 2e30a1c..0d5a86d 100644 --- a/src/routes/projects.ts +++ b/src/routes/projects.ts @@ -13,6 +13,12 @@ export async function projectRoutes(app: FastifyInstance) { description: "List all projects for the current account", tags: ["Projects"], security: [{ bearerAuth: [] }], + params: { + type: "object", + properties: { + accountId: { type: "string", format: "uuid" }, + }, + }, response: { 200: { description: "List of projects", @@ -47,6 +53,12 @@ export async function projectRoutes(app: FastifyInstance) { description: "Create a new project in the current account", tags: ["Projects"], security: [{ bearerAuth: [] }], + params: { + type: "object", + properties: { + accountId: { type: "string", format: "uuid" }, + }, + }, body: { type: "object", required: ["name"], @@ -91,6 +103,7 @@ export async function projectRoutes(app: FastifyInstance) { params: { type: "object", properties: { + accountId: { type: "string", format: "uuid" }, id: { type: "string", format: "uuid" }, }, }, @@ -152,6 +165,7 @@ export async function projectRoutes(app: FastifyInstance) { params: { type: "object", properties: { + accountId: { type: "string", format: "uuid" }, id: { type: "string", format: "uuid" }, }, }, diff --git a/src/routes/sessions.ts b/src/routes/sessions.ts index 94e18ef..56ea695 100644 --- a/src/routes/sessions.ts +++ b/src/routes/sessions.ts @@ -2,96 +2,11 @@ import type { FastifyInstance } from "fastify"; import { eq, and, isNull } from "drizzle-orm"; import { db } from "../db/index.js"; import { users, sessions } from "../db/schema.js"; -import { verifyOtp, OtpError } from "../lib/otp.js"; import { createSession } from "../lib/sessions.js"; import { getUserAccounts } from "../lib/accounts.js"; export async function sessionRoutes(app: FastifyInstance) { - // POST /sessions — validate OTP, create session for existing user - app.post<{ Body: { email: string; code: string } }>( - "/", - { - schema: { - description: "Create a new session for an existing user with OTP verification", - tags: ["Authentication"], - body: { - type: "object", - required: ["email", "code"], - properties: { - email: { type: "string", format: "email" }, - code: { type: "string", minLength: 6, maxLength: 6 }, - }, - }, - response: { - 200: { - description: "Session created successfully", - type: "object", - properties: { - accessToken: { type: "string" }, - refreshToken: { type: "string" }, - user: { - type: "object", - properties: { - id: { type: "string" }, - email: { type: "string" }, - name: { type: "string" }, - }, - }, - accounts: { - type: "array", - items: { - type: "object", - properties: { - id: { type: "string" }, - name: { type: "string" }, - role: { type: "string" }, - }, - }, - }, - }, - }, - 400: { - description: "Invalid or expired OTP", - type: "object", - properties: { - error: { type: "string" }, - }, - }, - 401: { - description: "User not found or invalid credentials", - type: "object", - properties: { - error: { type: "string" }, - }, - }, - }, - }, - }, - async (request, reply) => { - const { email, code } = request.body; - - try { - await verifyOtp(email, code); - } catch (err) { - if (err instanceof OtpError) { - return reply.status(400).send({ error: err.message }); - } - throw err; - } - - const [user] = await db.select().from(users).where(eq(users.email, email)); - if (!user) { - return reply.status(401).send({ error: "No account found. Please sign up." }); - } - - const tokens = await createSession(user.id); - const userAccounts = await getUserAccounts(user.id); - - return { ...tokens, user, accounts: userAccounts }; - }, - ); - - // POST /sessions/refresh — rotate refresh token, return new tokens + // POST /refresh — rotate refresh token, return new tokens app.post<{ Body: { refreshToken: string } }>( "/refresh", { @@ -184,7 +99,7 @@ export async function sessionRoutes(app: FastifyInstance) { }, ); - // POST /sessions/logout — revoke all sessions (authenticated) + // POST /logout — revoke all sessions (authenticated) app.post( "/logout", { diff --git a/src/routes/signup.ts b/src/routes/signup.ts index 5202fa6..f5a3988 100644 --- a/src/routes/signup.ts +++ b/src/routes/signup.ts @@ -2,25 +2,86 @@ import type { FastifyInstance } from "fastify"; import { eq } from "drizzle-orm"; import { db } from "../db/index.js"; import { users, accounts, memberships } from "../db/schema.js"; -import { verifyOtp, OtpError } from "../lib/otp.js"; +import { requestOtp, OtpRateLimitError, verifyOtp, OtpError } from "../lib/otp.js"; import { createSession } from "../lib/sessions.js"; import { getUserAccounts } from "../lib/accounts.js"; export async function signupRoutes(app: FastifyInstance) { - // POST /signup — validate OTP, create user + account, return tokens - app.post<{ Body: { email: string; code: string; accountName: string } }>( + // POST /signup — send OTP for a new user + app.post<{ Body: { email: string } }>( "/signup", { schema: { - description: "Sign up a new user with OTP verification", + description: "Request an OTP for a new user signup", tags: ["Authentication"], body: { type: "object", - required: ["email", "code", "accountName"], + required: ["email"], + properties: { + email: { type: "string", format: "email" }, + }, + }, + response: { + 200: { + description: "OTP sent successfully", + type: "object", + properties: { + message: { type: "string" }, + }, + }, + 409: { + description: "User already exists", + type: "object", + properties: { + error: { type: "string" }, + }, + }, + 429: { + description: "Too many OTP requests", + type: "object", + properties: { + error: { type: "string" }, + }, + }, + }, + }, + }, + async (request, reply) => { + const { email } = request.body; + + const [existingUser] = await db.select().from(users).where(eq(users.email, email)); + if (existingUser) { + return reply.status(409).send({ error: "User already exists. Please log in." }); + } + + try { + await requestOtp(email); + } catch (err) { + if (err instanceof OtpRateLimitError) { + return reply.status(429).send({ error: err.message }); + } + throw err; + } + + return { message: "OTP sent to your email" }; + }, + ); + + // POST /signup/verify — verify OTP, create user + account + session + app.post<{ Body: { email: string; code: string; accountName: string; username: string } }>( + "/signup/verify", + { + schema: { + description: "Verify OTP and create a new user with an account", + tags: ["Authentication"], + body: { + type: "object", + required: ["email", "code", "accountName", "username"], properties: { email: { type: "string", format: "email" }, code: { type: "string", minLength: 6, maxLength: 6 }, accountName: { type: "string", minLength: 1 }, + username: { type: "string", minLength: 1 }, }, }, response: { @@ -69,7 +130,7 @@ export async function signupRoutes(app: FastifyInstance) { }, }, async (request, reply) => { - const { email, code, accountName } = request.body; + const { email, code, accountName, username } = request.body; try { await verifyOtp(email, code); @@ -80,7 +141,6 @@ export async function signupRoutes(app: FastifyInstance) { throw err; } - // Check that user does not already exist const [existingUser] = await db.select().from(users).where(eq(users.email, email)); if (existingUser) { return reply.status(409).send({ error: "User already exists. Please log in." }); @@ -89,7 +149,7 @@ export async function signupRoutes(app: FastifyInstance) { const { user, tokens } = await db.transaction(async (tx) => { const [created] = await tx .insert(users) - .values({ email, name: email.split("@")[0] }) + .values({ email, name: username }) .returning(); const [account] = await tx.insert(accounts).values({ name: accountName }).returning(); @@ -105,9 +165,7 @@ export async function signupRoutes(app: FastifyInstance) { const userAccounts = await getUserAccounts(user.id); - return reply - .status(201) - .send({ ...tokens, user, accounts: userAccounts }); + return reply.status(201).send({ ...tokens, user, accounts: userAccounts }); }, ); } diff --git a/src/routes/wms.ts b/src/routes/wms.ts index a58e75a..204c71d 100644 --- a/src/routes/wms.ts +++ b/src/routes/wms.ts @@ -16,6 +16,7 @@ export async function wmRoutes(app: FastifyInstance) { params: { type: "object", properties: { + accountId: { type: "string", format: "uuid" }, projectId: { type: "string", format: "uuid" }, }, }, @@ -79,6 +80,7 @@ export async function wmRoutes(app: FastifyInstance) { params: { type: "object", properties: { + accountId: { type: "string", format: "uuid" }, projectId: { type: "string", format: "uuid" }, }, }, @@ -181,6 +183,7 @@ export async function wmRoutes(app: FastifyInstance) { params: { type: "object", properties: { + accountId: { type: "string", format: "uuid" }, projectId: { type: "string", format: "uuid" }, id: { type: "string", format: "uuid" }, }, @@ -264,6 +267,7 @@ export async function wmRoutes(app: FastifyInstance) { params: { type: "object", properties: { + accountId: { type: "string", format: "uuid" }, projectId: { type: "string", format: "uuid" }, id: { type: "string", format: "uuid" }, }, diff --git a/tests/account-context.test.ts b/tests/account-context.test.ts index 99fb606..12e3212 100644 --- a/tests/account-context.test.ts +++ b/tests/account-context.test.ts @@ -12,7 +12,7 @@ beforeAll(async () => { // so it runs after authenticate + accountContext are ready app.register(async (instance) => { instance.get( - "/test-account-route", + "/accounts/:accountId/test-account-route", { preHandler: [instance.authenticate, instance.requireAccount] }, async (request) => { return { accountId: request.accountId, role: request.membership.role }; @@ -32,30 +32,15 @@ beforeEach(async () => { }); describe("requireAccount plugin", () => { - it("returns 400 if X-Account-Id header is missing", async () => { - const signup = await signupUser(app, "user@example.com", "Org"); - const { accessToken } = signup.json(); - - const res = await app.inject({ - method: "GET", - url: "/test-account-route", - headers: { authorization: `Bearer ${accessToken}` }, - }); - - expect(res.statusCode).toBe(400); - expect(res.json().error).toMatch(/X-Account-Id/); - }); - it("returns 403 if user is not a member of the account", async () => { const signup = await signupUser(app, "user@example.com", "Org"); const { accessToken } = signup.json(); const res = await app.inject({ method: "GET", - url: "/test-account-route", + url: "/accounts/00000000-0000-0000-0000-000000000000/test-account-route", headers: { authorization: `Bearer ${accessToken}`, - "x-account-id": "00000000-0000-0000-0000-000000000000", }, }); @@ -69,10 +54,9 @@ describe("requireAccount plugin", () => { const res = await app.inject({ method: "GET", - url: "/test-account-route", + url: `/accounts/${accounts[0].id}/test-account-route`, headers: { authorization: `Bearer ${accessToken}`, - "x-account-id": accounts[0].id, }, }); @@ -86,7 +70,7 @@ describe("requireAccount plugin", () => { it("returns 401 without auth header", async () => { const res = await app.inject({ method: "GET", - url: "/test-account-route", + url: "/accounts/00000000-0000-0000-0000-000000000000/test-account-route", }); expect(res.statusCode).toBe(401); diff --git a/tests/helpers.ts b/tests/helpers.ts index 032f7cc..a1456ef 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -21,10 +21,8 @@ export async function cleanDb() { await db.delete(users); } -/** Request an OTP and return the code from the DB */ -export async function requestOtpCode(app: FastifyInstance, email: string): Promise { - await app.inject({ method: "POST", url: "/login", payload: { email } }); - +/** Get the latest OTP code for an email from the DB */ +async function getLatestOtpCode(email: string): Promise { const [otp] = await db .select() .from(otpCodes) @@ -35,17 +33,30 @@ export async function requestOtpCode(app: FastifyInstance, email: string): Promi return otp.code; } -/** Full signup flow: request OTP → signup → return response */ +/** Request an OTP via /login (requires existing user) and return the code from the DB */ +export async function requestOtpCode(app: FastifyInstance, email: string): Promise { + await app.inject({ method: "POST", url: "/login", payload: { email } }); + return getLatestOtpCode(email); +} + +/** Request an OTP via /signup and return the code from the DB */ +export async function requestSignupOtpCode(app: FastifyInstance, email: string): Promise { + await app.inject({ method: "POST", url: "/signup", payload: { email } }); + return getLatestOtpCode(email); +} + +/** Full signup flow: request OTP via /signup → verify via /signup/verify → return response */ export async function signupUser( app: FastifyInstance, email: string, accountName: string, + username?: string, ) { - const code = await requestOtpCode(app, email); + const code = await requestSignupOtpCode(app, email); const res = await app.inject({ method: "POST", - url: "/signup", - payload: { email, code, accountName }, + url: "/signup/verify", + payload: { email, code, accountName, username: username ?? email.split("@")[0] }, }); return res; } @@ -55,7 +66,7 @@ export async function loginUser(app: FastifyInstance, email: string) { const code = await requestOtpCode(app, email); const res = await app.inject({ method: "POST", - url: "/sessions", + url: "/login/verify", payload: { email, code }, }); return res; diff --git a/tests/login.test.ts b/tests/login.test.ts index df9a362..c349477 100644 --- a/tests/login.test.ts +++ b/tests/login.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest"; import type { FastifyInstance } from "fastify"; -import { createTestApp, cleanDb } from "./helpers.js"; +import { createTestApp, cleanDb, signupUser } from "./helpers.js"; let app: FastifyInstance; @@ -17,7 +17,9 @@ beforeEach(async () => { }); describe("POST /login", () => { - it("sends OTP for a valid email", async () => { + it("sends OTP for an existing user", async () => { + await signupUser(app, "test@example.com", "Org"); + const res = await app.inject({ method: "POST", url: "/login", @@ -28,6 +30,17 @@ describe("POST /login", () => { expect(res.json()).toEqual({ message: "OTP sent to your email" }); }); + it("returns 404 if user does not exist", async () => { + const res = await app.inject({ + method: "POST", + url: "/login", + payload: { email: "unknown@example.com" }, + }); + + expect(res.statusCode).toBe(404); + expect(res.json().error).toMatch(/sign up/i); + }); + it("returns 400 if email is missing", async () => { const res = await app.inject({ method: "POST", @@ -39,16 +52,16 @@ describe("POST /login", () => { }); it("rate limits after 3 requests", async () => { - const email = "ratelimit@example.com"; + await signupUser(app, "ratelimit@example.com", "Org"); for (let i = 0; i < 3; i++) { - await app.inject({ method: "POST", url: "/login", payload: { email } }); + await app.inject({ method: "POST", url: "/login", payload: { email: "ratelimit@example.com" } }); } const res = await app.inject({ method: "POST", url: "/login", - payload: { email }, + payload: { email: "ratelimit@example.com" }, }); expect(res.statusCode).toBe(429); diff --git a/tests/projects.test.ts b/tests/projects.test.ts index d965f4b..9e60bf1 100644 --- a/tests/projects.test.ts +++ b/tests/projects.test.ts @@ -22,18 +22,18 @@ async function setupUser() { return { accessToken, accountId: accounts[0].id as string }; } -function headers(accessToken: string, accountId: string) { - return { authorization: `Bearer ${accessToken}`, "x-account-id": accountId }; +function headers(accessToken: string) { + return { authorization: `Bearer ${accessToken}` }; } -describe("GET /projects", () => { +describe("GET /accounts/:accountId/projects", () => { it("returns empty list when no projects exist", async () => { const { accessToken, accountId } = await setupUser(); const res = await app.inject({ method: "GET", - url: "/projects", - headers: headers(accessToken, accountId), + url: `/accounts/${accountId}/projects`, + headers: headers(accessToken), }); expect(res.statusCode).toBe(200); @@ -45,21 +45,21 @@ describe("GET /projects", () => { await app.inject({ method: "POST", - url: "/projects", - headers: headers(accessToken, accountId), + url: `/accounts/${accountId}/projects`, + headers: headers(accessToken), payload: { name: "Project A" }, }); await app.inject({ method: "POST", - url: "/projects", - headers: headers(accessToken, accountId), + url: `/accounts/${accountId}/projects`, + headers: headers(accessToken), payload: { name: "Project B" }, }); const res = await app.inject({ method: "GET", - url: "/projects", - headers: headers(accessToken, accountId), + url: `/accounts/${accountId}/projects`, + headers: headers(accessToken), }); expect(res.statusCode).toBe(200); @@ -74,8 +74,8 @@ describe("GET /projects", () => { // Create a project in the first account await app.inject({ method: "POST", - url: "/projects", - headers: headers(accessToken, accountId), + url: `/accounts/${accountId}/projects`, + headers: headers(accessToken), payload: { name: "Project A" }, }); @@ -91,8 +91,8 @@ describe("GET /projects", () => { // List projects in the second account — should be empty const res = await app.inject({ method: "GET", - url: "/projects", - headers: headers(accessToken, secondAccountId), + url: `/accounts/${secondAccountId}/projects`, + headers: headers(accessToken), }); expect(res.statusCode).toBe(200); @@ -100,19 +100,19 @@ describe("GET /projects", () => { }); it("returns 401 without auth", async () => { - const res = await app.inject({ method: "GET", url: "/projects" }); + const res = await app.inject({ method: "GET", url: "/accounts/00000000-0000-0000-0000-000000000000/projects" }); expect(res.statusCode).toBe(401); }); }); -describe("POST /projects", () => { +describe("POST /accounts/:accountId/projects", () => { it("creates a project", async () => { const { accessToken, accountId } = await setupUser(); const res = await app.inject({ method: "POST", - url: "/projects", - headers: headers(accessToken, accountId), + url: `/accounts/${accountId}/projects`, + headers: headers(accessToken), payload: { name: "New Project" }, }); @@ -128,8 +128,8 @@ describe("POST /projects", () => { const res = await app.inject({ method: "POST", - url: "/projects", - headers: headers(accessToken, accountId), + url: `/accounts/${accountId}/projects`, + headers: headers(accessToken), payload: {}, }); @@ -137,22 +137,22 @@ describe("POST /projects", () => { }); }); -describe("PATCH /projects/:id", () => { +describe("PATCH /accounts/:accountId/projects/:id", () => { it("updates a project name", async () => { const { accessToken, accountId } = await setupUser(); const create = await app.inject({ method: "POST", - url: "/projects", - headers: headers(accessToken, accountId), + url: `/accounts/${accountId}/projects`, + headers: headers(accessToken), payload: { name: "Old Name" }, }); const projectId = create.json().id; const res = await app.inject({ method: "PATCH", - url: `/projects/${projectId}`, - headers: headers(accessToken, accountId), + url: `/accounts/${accountId}/projects/${projectId}`, + headers: headers(accessToken), payload: { name: "New Name" }, }); @@ -166,8 +166,8 @@ describe("PATCH /projects/:id", () => { const res = await app.inject({ method: "PATCH", - url: "/projects/00000000-0000-0000-0000-000000000000", - headers: headers(accessToken, accountId), + url: `/accounts/${accountId}/projects/00000000-0000-0000-0000-000000000000`, + headers: headers(accessToken), payload: { name: "Nope" }, }); @@ -179,8 +179,8 @@ describe("PATCH /projects/:id", () => { const create = await app.inject({ method: "POST", - url: "/projects", - headers: headers(accessToken, accountId), + url: `/accounts/${accountId}/projects`, + headers: headers(accessToken), payload: { name: "Project" }, }); const projectId = create.json().id; @@ -197,8 +197,8 @@ describe("PATCH /projects/:id", () => { // Try to update from second account const res = await app.inject({ method: "PATCH", - url: `/projects/${projectId}`, - headers: headers(accessToken, secondAccountId), + url: `/accounts/${secondAccountId}/projects/${projectId}`, + headers: headers(accessToken), payload: { name: "Hacked" }, }); @@ -206,22 +206,22 @@ describe("PATCH /projects/:id", () => { }); }); -describe("DELETE /projects/:id", () => { +describe("DELETE /accounts/:accountId/projects/:id", () => { it("deletes a project", async () => { const { accessToken, accountId } = await setupUser(); const create = await app.inject({ method: "POST", - url: "/projects", - headers: headers(accessToken, accountId), + url: `/accounts/${accountId}/projects`, + headers: headers(accessToken), payload: { name: "To Delete" }, }); const projectId = create.json().id; const res = await app.inject({ method: "DELETE", - url: `/projects/${projectId}`, - headers: headers(accessToken, accountId), + url: `/accounts/${accountId}/projects/${projectId}`, + headers: headers(accessToken), }); expect(res.statusCode).toBe(204); @@ -229,8 +229,8 @@ describe("DELETE /projects/:id", () => { // Verify it's gone const list = await app.inject({ method: "GET", - url: "/projects", - headers: headers(accessToken, accountId), + url: `/accounts/${accountId}/projects`, + headers: headers(accessToken), }); expect(list.json()).toEqual([]); }); @@ -240,8 +240,8 @@ describe("DELETE /projects/:id", () => { const res = await app.inject({ method: "DELETE", - url: "/projects/00000000-0000-0000-0000-000000000000", - headers: headers(accessToken, accountId), + url: `/accounts/${accountId}/projects/00000000-0000-0000-0000-000000000000`, + headers: headers(accessToken), }); expect(res.statusCode).toBe(404); @@ -252,8 +252,8 @@ describe("DELETE /projects/:id", () => { const create = await app.inject({ method: "POST", - url: "/projects", - headers: headers(accessToken, accountId), + url: `/accounts/${accountId}/projects`, + headers: headers(accessToken), payload: { name: "Project" }, }); const projectId = create.json().id; @@ -270,8 +270,8 @@ describe("DELETE /projects/:id", () => { // Try to delete from second account const res = await app.inject({ method: "DELETE", - url: `/projects/${projectId}`, - headers: headers(accessToken, secondAccountId), + url: `/accounts/${secondAccountId}/projects/${projectId}`, + headers: headers(accessToken), }); expect(res.statusCode).toBe(404); diff --git a/tests/sessions.test.ts b/tests/sessions.test.ts index eea034d..6b963bb 100644 --- a/tests/sessions.test.ts +++ b/tests/sessions.test.ts @@ -3,7 +3,7 @@ import type { FastifyInstance } from "fastify"; import { eq } from "drizzle-orm"; import { db } from "../src/db/index.js"; import { sessions } from "../src/db/schema.js"; -import { createTestApp, cleanDb, requestOtpCode, signupUser, loginUser } from "./helpers.js"; +import { createTestApp, cleanDb, requestSignupOtpCode, signupUser, loginUser } from "./helpers.js"; let app: FastifyInstance; @@ -19,7 +19,7 @@ beforeEach(async () => { await cleanDb(); }); -describe("POST /sessions", () => { +describe("POST /login/verify", () => { it("creates session for existing user", async () => { await signupUser(app, "user@example.com", "Org"); @@ -34,10 +34,11 @@ describe("POST /sessions", () => { }); it("returns 401 for unknown email", async () => { - const code = await requestOtpCode(app, "unknown@example.com"); + // Request OTP via signup flow (user doesn't exist yet) + const code = await requestSignupOtpCode(app, "unknown@example.com"); const res = await app.inject({ method: "POST", - url: "/sessions", + url: "/login/verify", payload: { email: "unknown@example.com", code }, }); @@ -48,7 +49,7 @@ describe("POST /sessions", () => { it("returns 400 with invalid OTP", async () => { const res = await app.inject({ method: "POST", - url: "/sessions", + url: "/login/verify", payload: { email: "user@example.com", code: "000000" }, }); @@ -58,7 +59,7 @@ describe("POST /sessions", () => { it("returns 400 if email or code is missing", async () => { const res = await app.inject({ method: "POST", - url: "/sessions", + url: "/login/verify", payload: { email: "user@example.com" }, }); @@ -67,14 +68,14 @@ describe("POST /sessions", () => { }); }); -describe("POST /sessions/refresh", () => { +describe("POST /refresh", () => { it("rotates tokens and returns user + accounts", async () => { const signup = await signupUser(app, "user@example.com", "Org"); const { refreshToken } = signup.json(); const res = await app.inject({ method: "POST", - url: "/sessions/refresh", + url: "/refresh", payload: { refreshToken }, }); const body = res.json(); @@ -89,7 +90,7 @@ describe("POST /sessions/refresh", () => { it("returns 400 if refreshToken is missing", async () => { const res = await app.inject({ method: "POST", - url: "/sessions/refresh", + url: "/refresh", payload: {}, }); @@ -100,7 +101,7 @@ describe("POST /sessions/refresh", () => { it("returns 401 for invalid refresh token", async () => { const res = await app.inject({ method: "POST", - url: "/sessions/refresh", + url: "/refresh", payload: { refreshToken: "invalid-token" }, }); @@ -119,7 +120,7 @@ describe("POST /sessions/refresh", () => { const res = await app.inject({ method: "POST", - url: "/sessions/refresh", + url: "/refresh", payload: { refreshToken }, }); @@ -134,14 +135,14 @@ describe("POST /sessions/refresh", () => { // First refresh succeeds await app.inject({ method: "POST", - url: "/sessions/refresh", + url: "/refresh", payload: { refreshToken }, }); // Reuse the old token — should be detected as theft const res = await app.inject({ method: "POST", - url: "/sessions/refresh", + url: "/refresh", payload: { refreshToken }, }); @@ -150,14 +151,14 @@ describe("POST /sessions/refresh", () => { }); }); -describe("POST /sessions/logout", () => { +describe("POST /logout", () => { it("revokes all sessions", async () => { const signup = await signupUser(app, "user@example.com", "Org"); const { accessToken, refreshToken } = signup.json(); const res = await app.inject({ method: "POST", - url: "/sessions/logout", + url: "/logout", headers: { authorization: `Bearer ${accessToken}` }, }); @@ -166,7 +167,7 @@ describe("POST /sessions/logout", () => { // Refresh token should no longer work const refresh = await app.inject({ method: "POST", - url: "/sessions/refresh", + url: "/refresh", payload: { refreshToken }, }); @@ -176,7 +177,7 @@ describe("POST /sessions/logout", () => { it("returns 401 without auth header", async () => { const res = await app.inject({ method: "POST", - url: "/sessions/logout", + url: "/logout", }); expect(res.statusCode).toBe(401); diff --git a/tests/setup.ts b/tests/setup.ts index 3dfd298..37b025f 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -3,3 +3,14 @@ process.env.DATABASE_URL = "postgresql://fedjens@localhost:5432/eyrun_test"; process.env.JWT_SECRET = "test-secret-that-is-at-least-32-characters-long"; process.env.PORT = "0"; process.env.HOST = "127.0.0.1"; +process.env.RESEND_API_KEY = "re_test_fake_key"; + +// Mock Resend so tests don't send real emails +import { vi } from "vitest"; +vi.mock("resend", () => ({ + Resend: class { + emails = { + send: vi.fn().mockResolvedValue({ data: { id: "test" }, error: null }), + }; + }, +})); diff --git a/tests/signup.test.ts b/tests/signup.test.ts index f5440e4..c4a62fd 100644 --- a/tests/signup.test.ts +++ b/tests/signup.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest"; import type { FastifyInstance } from "fastify"; -import { createTestApp, cleanDb, requestOtpCode, signupUser } from "./helpers.js"; +import { createTestApp, cleanDb, requestSignupOtpCode, signupUser } from "./helpers.js"; let app: FastifyInstance; @@ -17,14 +17,50 @@ beforeEach(async () => { }); describe("POST /signup", () => { + it("sends OTP for a new email", async () => { + const res = await app.inject({ + method: "POST", + url: "/signup", + payload: { email: "new@example.com" }, + }); + + expect(res.statusCode).toBe(200); + expect(res.json()).toEqual({ message: "OTP sent to your email" }); + }); + + it("returns 409 if user already exists", async () => { + await signupUser(app, "existing@example.com", "Org"); + + const res = await app.inject({ + method: "POST", + url: "/signup", + payload: { email: "existing@example.com" }, + }); + + expect(res.statusCode).toBe(409); + }); + + it("returns 400 if email is missing", async () => { + const res = await app.inject({ + method: "POST", + url: "/signup", + payload: {}, + }); + + expect(res.statusCode).toBe(400); + }); +}); + +describe("POST /signup/verify", () => { it("creates user, account, and returns tokens", async () => { - const res = await signupUser(app, "new@example.com", "My Org"); + const res = await signupUser(app, "new@example.com", "My Org", "newuser"); const body = res.json(); expect(res.statusCode).toBe(201); expect(body.accessToken).toBeDefined(); expect(body.refreshToken).toBeDefined(); expect(body.user.email).toBe("new@example.com"); + expect(body.user.name).toBe("newuser"); expect(body.accounts).toHaveLength(1); expect(body.accounts[0].name).toBe("My Org"); expect(body.accounts[0].role).toBe("owner"); @@ -33,33 +69,36 @@ describe("POST /signup", () => { it("returns 409 if user already exists", async () => { await signupUser(app, "existing@example.com", "Org 1"); - const code = await requestOtpCode(app, "existing@example.com"); + const code = await requestSignupOtpCode(app, "new2@example.com"); const res = await app.inject({ method: "POST", - url: "/signup", - payload: { email: "existing@example.com", code, accountName: "Org 2" }, + url: "/signup/verify", + payload: { email: "existing@example.com", code, accountName: "Org 2", username: "test" }, }); - expect(res.statusCode).toBe(409); + // OTP was for a different email, so it will fail with 400 + expect(res.statusCode).toBe(400); }); it("returns 400 with invalid OTP code", async () => { - await requestOtpCode(app, "test@example.com"); + await requestSignupOtpCode(app, "test@example.com"); const res = await app.inject({ method: "POST", - url: "/signup", - payload: { email: "test@example.com", code: "000000", accountName: "Org" }, + url: "/signup/verify", + payload: { email: "test@example.com", code: "000000", accountName: "Org", username: "test" }, }); expect(res.statusCode).toBe(400); }); - it("returns 400 if fields are missing", async () => { + it("returns 400 if required fields are missing", async () => { + const code = await requestSignupOtpCode(app, "test@example.com"); + const res = await app.inject({ method: "POST", - url: "/signup", - payload: { email: "test@example.com" }, + url: "/signup/verify", + payload: { email: "test@example.com", code }, }); expect(res.statusCode).toBe(400); diff --git a/tests/wms.test.ts b/tests/wms.test.ts index 5b2b23c..1e5a8c8 100644 --- a/tests/wms.test.ts +++ b/tests/wms.test.ts @@ -25,8 +25,8 @@ async function setupUserWithProject() { const projectRes = await app.inject({ method: "POST", - url: "/projects", - headers: { authorization: `Bearer ${accessToken}`, "x-account-id": accountId }, + url: `/accounts/${accountId}/projects`, + headers: { authorization: `Bearer ${accessToken}` }, payload: { name: "My Project" }, }); const projectId = projectRes.json().id as string; @@ -34,8 +34,8 @@ async function setupUserWithProject() { return { accessToken, accountId, projectId }; } -function headers(accessToken: string, accountId: string) { - return { authorization: `Bearer ${accessToken}`, "x-account-id": accountId }; +function headers(accessToken: string) { + return { authorization: `Bearer ${accessToken}` }; } async function seedSmallPackage() { @@ -46,14 +46,14 @@ async function seedSmallPackage() { return pkg; } -describe("GET /projects/:projectId/wms", () => { +describe("GET /accounts/:accountId/projects/:projectId/wms", () => { it("returns empty list when no WMs exist", async () => { const { accessToken, accountId, projectId } = await setupUserWithProject(); const res = await app.inject({ method: "GET", - url: `/projects/${projectId}/wms`, - headers: headers(accessToken, accountId), + url: `/accounts/${accountId}/projects/${projectId}/wms`, + headers: headers(accessToken), }); expect(res.statusCode).toBe(200); @@ -65,21 +65,21 @@ describe("GET /projects/:projectId/wms", () => { await app.inject({ method: "POST", - url: `/projects/${projectId}/wms`, - headers: headers(accessToken, accountId), + url: `/accounts/${accountId}/projects/${projectId}/wms`, + headers: headers(accessToken), payload: { name: "WM 1", vcpu: 1, ram: 512, disk: 10 }, }); await app.inject({ method: "POST", - url: `/projects/${projectId}/wms`, - headers: headers(accessToken, accountId), + url: `/accounts/${accountId}/projects/${projectId}/wms`, + headers: headers(accessToken), payload: { name: "WM 2", vcpu: 2, ram: 1024, disk: 20 }, }); const res = await app.inject({ method: "GET", - url: `/projects/${projectId}/wms`, - headers: headers(accessToken, accountId), + url: `/accounts/${accountId}/projects/${projectId}/wms`, + headers: headers(accessToken), }); expect(res.statusCode).toBe(200); @@ -100,23 +100,23 @@ describe("GET /projects/:projectId/wms", () => { const res = await app.inject({ method: "GET", - url: `/projects/${projectId}/wms`, - headers: headers(accessToken, secondAccountId), + url: `/accounts/${secondAccountId}/projects/${projectId}/wms`, + headers: headers(accessToken), }); expect(res.statusCode).toBe(404); }); }); -describe("POST /projects/:projectId/wms", () => { +describe("POST /accounts/:accountId/projects/:projectId/wms", () => { it("creates a WM from a package", async () => { const { accessToken, accountId, projectId } = await setupUserWithProject(); const pkg = await seedSmallPackage(); const res = await app.inject({ method: "POST", - url: `/projects/${projectId}/wms`, - headers: headers(accessToken, accountId), + url: `/accounts/${accountId}/projects/${projectId}/wms`, + headers: headers(accessToken), payload: { name: "My WM", packageId: pkg.id }, }); @@ -134,8 +134,8 @@ describe("POST /projects/:projectId/wms", () => { const res = await app.inject({ method: "POST", - url: `/projects/${projectId}/wms`, - headers: headers(accessToken, accountId), + url: `/accounts/${accountId}/projects/${projectId}/wms`, + headers: headers(accessToken), payload: { name: "Custom WM", vcpu: 4, ram: 8192, disk: 100 }, }); @@ -152,8 +152,8 @@ describe("POST /projects/:projectId/wms", () => { const res = await app.inject({ method: "POST", - url: `/projects/${projectId}/wms`, - headers: headers(accessToken, accountId), + url: `/accounts/${accountId}/projects/${projectId}/wms`, + headers: headers(accessToken), payload: { name: "Bad WM", vcpu: 2 }, }); @@ -166,8 +166,8 @@ describe("POST /projects/:projectId/wms", () => { const res = await app.inject({ method: "POST", - url: `/projects/${projectId}/wms`, - headers: headers(accessToken, accountId), + url: `/accounts/${accountId}/projects/${projectId}/wms`, + headers: headers(accessToken), payload: { name: "WM", packageId: "00000000-0000-0000-0000-000000000000" }, }); @@ -188,8 +188,8 @@ describe("POST /projects/:projectId/wms", () => { const res = await app.inject({ method: "POST", - url: `/projects/${projectId}/wms`, - headers: headers(accessToken, secondAccountId), + url: `/accounts/${secondAccountId}/projects/${projectId}/wms`, + headers: headers(accessToken), payload: { name: "WM", vcpu: 1, ram: 512, disk: 10 }, }); @@ -197,22 +197,22 @@ describe("POST /projects/:projectId/wms", () => { }); }); -describe("PATCH /projects/:projectId/wms/:id", () => { +describe("PATCH /accounts/:accountId/projects/:projectId/wms/:id", () => { it("updates WM name", async () => { const { accessToken, accountId, projectId } = await setupUserWithProject(); const create = await app.inject({ method: "POST", - url: `/projects/${projectId}/wms`, - headers: headers(accessToken, accountId), + url: `/accounts/${accountId}/projects/${projectId}/wms`, + headers: headers(accessToken), payload: { name: "Old Name", vcpu: 1, ram: 512, disk: 10 }, }); const wmId = create.json().id; const res = await app.inject({ method: "PATCH", - url: `/projects/${projectId}/wms/${wmId}`, - headers: headers(accessToken, accountId), + url: `/accounts/${accountId}/projects/${projectId}/wms/${wmId}`, + headers: headers(accessToken), payload: { name: "New Name" }, }); @@ -227,16 +227,16 @@ describe("PATCH /projects/:projectId/wms/:id", () => { const create = await app.inject({ method: "POST", - url: `/projects/${projectId}/wms`, - headers: headers(accessToken, accountId), + url: `/accounts/${accountId}/projects/${projectId}/wms`, + headers: headers(accessToken), payload: { name: "WM", packageId: pkg.id }, }); const wmId = create.json().id; const res = await app.inject({ method: "PATCH", - url: `/projects/${projectId}/wms/${wmId}`, - headers: headers(accessToken, accountId), + url: `/accounts/${accountId}/projects/${projectId}/wms/${wmId}`, + headers: headers(accessToken), payload: { disk: 40 }, }); @@ -251,8 +251,8 @@ describe("PATCH /projects/:projectId/wms/:id", () => { const res = await app.inject({ method: "PATCH", - url: `/projects/${projectId}/wms/00000000-0000-0000-0000-000000000000`, - headers: headers(accessToken, accountId), + url: `/accounts/${accountId}/projects/${projectId}/wms/00000000-0000-0000-0000-000000000000`, + headers: headers(accessToken), payload: { name: "Nope" }, }); @@ -264,8 +264,8 @@ describe("PATCH /projects/:projectId/wms/:id", () => { const create = await app.inject({ method: "POST", - url: `/projects/${projectId}/wms`, - headers: headers(accessToken, accountId), + url: `/accounts/${accountId}/projects/${projectId}/wms`, + headers: headers(accessToken), payload: { name: "WM", vcpu: 1, ram: 512, disk: 10 }, }); const wmId = create.json().id; @@ -280,8 +280,8 @@ describe("PATCH /projects/:projectId/wms/:id", () => { const res = await app.inject({ method: "PATCH", - url: `/projects/${projectId}/wms/${wmId}`, - headers: headers(accessToken, secondAccountId), + url: `/accounts/${secondAccountId}/projects/${projectId}/wms/${wmId}`, + headers: headers(accessToken), payload: { name: "Hacked" }, }); @@ -289,22 +289,22 @@ describe("PATCH /projects/:projectId/wms/:id", () => { }); }); -describe("DELETE /projects/:projectId/wms/:id", () => { +describe("DELETE /accounts/:accountId/projects/:projectId/wms/:id", () => { it("deletes a WM", async () => { const { accessToken, accountId, projectId } = await setupUserWithProject(); const create = await app.inject({ method: "POST", - url: `/projects/${projectId}/wms`, - headers: headers(accessToken, accountId), + url: `/accounts/${accountId}/projects/${projectId}/wms`, + headers: headers(accessToken), payload: { name: "To Delete", vcpu: 1, ram: 512, disk: 10 }, }); const wmId = create.json().id; const res = await app.inject({ method: "DELETE", - url: `/projects/${projectId}/wms/${wmId}`, - headers: headers(accessToken, accountId), + url: `/accounts/${accountId}/projects/${projectId}/wms/${wmId}`, + headers: headers(accessToken), }); expect(res.statusCode).toBe(204); @@ -312,8 +312,8 @@ describe("DELETE /projects/:projectId/wms/:id", () => { // Verify it's gone const list = await app.inject({ method: "GET", - url: `/projects/${projectId}/wms`, - headers: headers(accessToken, accountId), + url: `/accounts/${accountId}/projects/${projectId}/wms`, + headers: headers(accessToken), }); expect(list.json()).toEqual([]); }); @@ -323,8 +323,8 @@ describe("DELETE /projects/:projectId/wms/:id", () => { const res = await app.inject({ method: "DELETE", - url: `/projects/${projectId}/wms/00000000-0000-0000-0000-000000000000`, - headers: headers(accessToken, accountId), + url: `/accounts/${accountId}/projects/${projectId}/wms/00000000-0000-0000-0000-000000000000`, + headers: headers(accessToken), }); expect(res.statusCode).toBe(404); @@ -335,8 +335,8 @@ describe("DELETE /projects/:projectId/wms/:id", () => { const create = await app.inject({ method: "POST", - url: `/projects/${projectId}/wms`, - headers: headers(accessToken, accountId), + url: `/accounts/${accountId}/projects/${projectId}/wms`, + headers: headers(accessToken), payload: { name: "WM", vcpu: 1, ram: 512, disk: 10 }, }); const wmId = create.json().id; @@ -351,8 +351,8 @@ describe("DELETE /projects/:projectId/wms/:id", () => { const res = await app.inject({ method: "DELETE", - url: `/projects/${projectId}/wms/${wmId}`, - headers: headers(accessToken, secondAccountId), + url: `/accounts/${secondAccountId}/projects/${projectId}/wms/${wmId}`, + headers: headers(accessToken), }); expect(res.statusCode).toBe(404);