diff --git a/.gitea/workflows/qa-build.yml b/.gitea/workflows/qa-build.yml new file mode 100644 index 0000000..db2659b --- /dev/null +++ b/.gitea/workflows/qa-build.yml @@ -0,0 +1,152 @@ +name: Build QA Image + +# Manual only. Builds the QA image from a chosen ref, pushes it to the Gitea +# container registry, and emails when it is ready to redeploy. +# +# It deliberately does NOT restart the QA stack. Redeploying stays a human +# action in Portainer, so nothing changes what is running without someone +# deciding it should. +on: + workflow_dispatch: + inputs: + ref: + description: Branch, tag, or commit to build + required: true + default: main + +env: + IMAGE: gitea.bermudalamb.synology.me/bermudalamb/redefined-designs + # The build runs against a Docker-in-Docker service rather than the host + # daemon. Mounting the host socket into the runner would give every workflow + # on every branch root-equivalent control of the NAS, production included. + # Because the image is pushed to a registry, it does not need to survive in + # the build daemon. + DOCKER_HOST: tcp://docker:2375 + +jobs: + build: + runs-on: ubuntu-latest + + services: + docker: + image: docker:27-dind + options: --privileged + env: + DOCKER_TLS_CERTDIR: "" + + steps: + - name: Check required configuration + run: | + missing="" + [ -n "${{ secrets.REGISTRY_TOKEN }}" ] || missing="$missing REGISTRY_TOKEN" + [ -n "${{ vars.REGISTRY_USER }}" ] || missing="$missing REGISTRY_USER" + [ -n "${{ secrets.BREVO_API_KEY }}" ] || missing="$missing BREVO_API_KEY" + [ -n "${{ vars.QA_NOTIFY_TO }}" ] || missing="$missing QA_NOTIFY_TO" + [ -n "${{ vars.QA_NOTIFY_FROM }}" ] || missing="$missing QA_NOTIFY_FROM" + if [ -n "$missing" ]; then + echo "::error::Missing configuration:$missing" + echo "Secrets go in Settings > Actions > Secrets; variables in Settings > Actions > Variables." + exit 1 + fi + echo "All required secrets and variables are present." + + - name: Checkout ${{ inputs.ref }} + uses: actions/checkout@v4 + with: + ref: ${{ inputs.ref }} + fetch-depth: 0 + + - name: Ensure a docker CLI is available + run: | + if command -v docker >/dev/null 2>&1; then + echo "docker CLI already present: $(docker --version)" + exit 0 + fi + echo "docker CLI missing from the runner image; installing the static binary." + curl -fsSL https://download.docker.com/linux/static/stable/x86_64/docker-27.3.1.tgz -o /tmp/docker.tgz + tar -xzf /tmp/docker.tgz -C /tmp + install -m 0755 /tmp/docker/docker /usr/local/bin/docker + docker --version + + - name: Wait for the build daemon + run: | + # A privileged service container is the one runner capability this + # workflow cannot verify in advance. Fail here with an explanation + # rather than at `docker build` with a connection refused. + for i in $(seq 1 30); do + if docker info >/dev/null 2>&1; then + echo "Build daemon reachable after ${i}s." + exit 0 + fi + sleep 1 + done + echo "::error::No Docker daemon at $DOCKER_HOST after 30s." + echo "The dind service needs privileged containers. If the runner" + echo "forbids them, this workflow cannot build without host socket access." + exit 1 + + - name: Record what is being built + id: meta + run: | + echo "sha=$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT" + echo "full_sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + { + echo "subject<> "$GITHUB_OUTPUT" + + - name: Log in to the Gitea registry + run: | + echo "${{ secrets.REGISTRY_TOKEN }}" \ + | docker login gitea.bermudalamb.synology.me \ + -u "${{ vars.REGISTRY_USER }}" --password-stdin + + - name: Build and push + run: | + # Tagged twice: :qa is what the stack pulls, and the commit tag makes + # it possible to tell what is actually deployed and to roll back to a + # specific build rather than "the previous one". + docker build --no-cache \ + -t "$IMAGE:qa" \ + -t "$IMAGE:${{ steps.meta.outputs.sha }}" \ + . + docker push "$IMAGE:qa" + docker push "$IMAGE:${{ steps.meta.outputs.sha }}" + + - name: Email that QA is ready to redeploy + run: | + cat > /tmp/mail.json <A QA image has been built and pushed.

To deploy it: open the redefined-designs-qa stack in Portainer and redeploy with Pull latest image enabled.

Migrations run automatically as the container starts — check docker logs redefined-designs-qa-syn shows the migration output before listening on 3000, and that it appears only once.

" + } + JSON + code=$(curl -sS -o /tmp/mail-response.json -w '%{http_code}' \ + -X POST https://api.brevo.com/v3/smtp/email \ + -H "api-key: ${{ secrets.BREVO_API_KEY }}" \ + -H "Content-Type: application/json" \ + --data @/tmp/mail.json) + echo "Brevo responded $code" + if [ "$code" -ge 300 ]; then + cat /tmp/mail-response.json + # The image is already pushed and usable at this point, so a failed + # notification must not report the build as failed. + echo "::warning::Image pushed successfully, but the notification email failed." + fi + + - name: Summary + run: | + { + echo "### QA image pushed" + echo "" + echo "| | |" + echo "|---|---|" + echo "| Ref | \`${{ inputs.ref }}\` |" + echo "| Commit | \`${{ steps.meta.outputs.full_sha }}\` |" + echo "| Tags | \`$IMAGE:qa\`, \`$IMAGE:${{ steps.meta.outputs.sha }}\` |" + echo "" + echo "Redeploy the \`redefined-designs-qa\` stack in Portainer with **Pull latest image** enabled." + } >> "$GITHUB_STEP_SUMMARY" diff --git a/backend/package-lock.json b/backend/package-lock.json index 1c477bc..8d95673 100755 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -11,6 +11,7 @@ "bcryptjs": "^2.4.3", "cookie-parser": "^1.4.6", "express": "^4.19.2", + "express-rate-limit": "^8.6.2", "multer": "^1.4.5-lts.1", "node-cron": "^3.0.3", "node-pg-migrate": "^7.6.1", @@ -2865,6 +2866,48 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-rate-limit": { + "version": "8.6.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.6.2.tgz", + "integrity": "sha512-YH4ru+eOJxQABscKFfRCy9R7x9QFGdezclVMwwgFFndzS2Xnm0uo6B0ABZsLhcpeptGv2qvuJVWlQr9gQZoC3A==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/express-rate-limit/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/express-rate-limit/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==", + "license": "MIT" + }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -3333,6 +3376,15 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/ip-address": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.5.0.tgz", + "integrity": "sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", diff --git a/backend/package.json b/backend/package.json index a740324..72b4688 100755 --- a/backend/package.json +++ b/backend/package.json @@ -19,30 +19,31 @@ "migrate:create": "node-pg-migrate create --migration-file-language js" }, "dependencies": { - "express": "^4.19.2", - "pg": "^8.12.0", - "multer": "^1.4.5-lts.1", "bcryptjs": "^2.4.3", "cookie-parser": "^1.4.6", - "nodemailer": "^6.9.14", + "express": "^4.19.2", + "express-rate-limit": "^8.6.2", + "multer": "^1.4.5-lts.1", "node-cron": "^3.0.3", - "node-pg-migrate": "^7.6.1" + "node-pg-migrate": "^7.6.1", + "nodemailer": "^6.9.14", + "pg": "^8.12.0" }, "devDependencies": { - "typescript": "^5.5.4", - "tsx": "^4.16.5", - "@types/express": "^4.17.21", - "@types/node": "^20.14.15", - "@types/multer": "^1.4.11", - "@types/pg": "^8.11.6", "@types/bcryptjs": "^2.4.6", "@types/cookie-parser": "^1.4.7", - "@types/nodemailer": "^6.4.15", - "@types/node-cron": "^3.0.11", - "jest": "^29.7.0", - "ts-jest": "^29.2.4", + "@types/express": "^4.17.21", "@types/jest": "^29.5.12", + "@types/multer": "^1.4.11", + "@types/node": "^20.14.15", + "@types/node-cron": "^3.0.11", + "@types/nodemailer": "^6.4.15", + "@types/pg": "^8.11.6", + "@types/supertest": "^6.0.2", + "jest": "^29.7.0", "supertest": "^7.0.0", - "@types/supertest": "^6.0.2" + "ts-jest": "^29.2.4", + "tsx": "^4.16.5", + "typescript": "^5.5.4" } -} \ No newline at end of file +} diff --git a/backend/src/rateLimit.ts b/backend/src/rateLimit.ts new file mode 100644 index 0000000..2cfa2ca --- /dev/null +++ b/backend/src/rateLimit.ts @@ -0,0 +1,35 @@ +import rateLimit from 'express-rate-limit'; +import { Request } from 'express'; + +// First rate limiting in the codebase. The password-reset endpoints need it +// most: without one, anyone can make the server send unlimited mail to any +// address. Login and registration are the obvious next candidates. +// +// The default in-memory store suits a single-instance deployment, which this +// is. Running more than one app container would need a shared store, or each +// instance would enforce its own separate allowance. + +const WINDOW_MS = 15 * 60 * 1000; +const MAX_REQUESTS = 5; + +// Keyed on caller *and* address rather than caller alone. Keying on IP only +// would let one person's reset attempts lock out everyone behind the same +// NAT or reverse proxy — and everything here arrives via Nginx Proxy Manager, +// so a great many customers share an apparent address. +// +// This key only makes sense on a request that carries an email. Applying the +// same limiter to an endpoint without one collapses every caller into a single +// `ip:` bucket, which is a shared allowance rather than a per-caller one. +function keyByCallerAndEmail(req: Request): string { + const email = typeof req.body?.email === 'string' ? req.body.email.toLowerCase().trim() : ''; + return `${req.ip}:${email}`; +} + +export const passwordResetRequestLimiter = rateLimit({ + windowMs: WINDOW_MS, + limit: MAX_REQUESTS, + keyGenerator: keyByCallerAndEmail, + standardHeaders: 'draft-7', + legacyHeaders: false, + message: { error: 'too many attempts, please try again later' } +}); diff --git a/backend/src/routes/customers.ts b/backend/src/routes/customers.ts index d078a37..5e33b12 100755 --- a/backend/src/routes/customers.ts +++ b/backend/src/routes/customers.ts @@ -5,6 +5,8 @@ import { pool } from '../db'; import { requireCustomer } from '../middleware/customerAuth'; import { sendMail } from '../mailer'; import { MARKETING_CONSENT_TEXT, isValidEmail } from '../utils'; +import { asyncRoute } from '../asyncRoute'; +import { passwordResetRequestLimiter } from '../rateLimit'; const router = Router(); @@ -93,6 +95,98 @@ router.post('/verify-email', async (req: Request, res: Response) => { res.json({ status: 'verified' }); }); +const RESET_TOKEN_TTL_MS = 60 * 60 * 1000; + +// Always answers 200, whether or not the address has an account. A response +// that differed would let anyone test addresses for membership. +// +// Note /register still reveals existence via its 409 on a duplicate address, +// so this protection is currently partial — closing that is its own change. +router.post('/request-password-reset', passwordResetRequestLimiter, asyncRoute(async (req: Request, res: Response) => { + const email = String(req.body?.email || '').toLowerCase().trim(); + if (!email || !isValidEmail(email)) { + return res.status(400).json({ error: 'a valid email is required' }); + } + + const { rows } = await pool.query(`SELECT * FROM customers WHERE email = $1`, [email]); + const customer = rows[0]; + + if (customer) { + // Supersede any outstanding token, so a link cannot be resurrected later + // from an older message in the customer's inbox. + await pool.query(`DELETE FROM customer_tokens WHERE customer_id = $1 AND kind = 'password_reset'`, [customer.id]); + + const token = crypto.randomBytes(32).toString('hex'); + await pool.query( + `INSERT INTO customer_tokens (token, customer_id, kind, expires_at) VALUES ($1, $2, 'password_reset', $3)`, + [token, customer.id, new Date(Date.now() + RESET_TOKEN_TTL_MS)] + ); + + const resetUrl = `${process.env.PUBLIC_URL}/reset-password?token=${token}`; + sendMail( + customer.email, + 'Reset your Redefined Designs password', + `

Someone asked to reset the password for this account.

+

Choose a new password. This link expires in one hour.

+

If this wasn't you, you can ignore this email — your password has not changed.

` + ).catch(err => console.error('password reset email send failed', err)); + } + + res.json({ status: 'sent' }); +})); + +// Deliberately not rate limited. The limiter above is keyed on the submitted +// email, which this endpoint does not carry, so reusing it would put every +// customer completing a reset into one shared bucket. Nor is a limit needed +// here: the token is 32 random bytes, single-use, and expires in an hour, and +// the expensive bcrypt hash only runs *after* the token has been matched, so +// invalid guesses cost a single indexed lookup. +router.post('/reset-password', asyncRoute(async (req: Request, res: Response) => { + const { token, password } = req.body || {}; + if (!password || String(password).length < 8) { + // Checked before the token is looked at, so a rejected attempt does not + // consume the customer's only reset link. + return res.status(400).json({ error: 'password must be at least 8 characters' }); + } + + const { rows } = await pool.query( + `SELECT * FROM customer_tokens WHERE token = $1 AND kind = 'password_reset' AND expires_at > now()`, + [token] + ); + if (!rows.length) return res.status(400).json({ error: 'invalid or expired token' }); + const customerId = rows[0].customer_id; + + const passwordHash = await bcrypt.hash(String(password), 12); + + const client = await pool.connect(); + try { + await client.query('BEGIN'); + await client.query( + // The customer has demonstrably received mail at this address, which is + // exactly what verification proves, so an unverified address becomes + // verified here. + `UPDATE customers SET password_hash = $1, email_verified = true WHERE id = $2`, + [passwordHash, customerId] + ); + // Every existing session goes, including any an attacker holds. Without + // this, a reset prompted by a compromise leaves the intruder signed in for + // up to 30 days. + await client.query(`DELETE FROM customer_sessions WHERE customer_id = $1`, [customerId]); + await client.query(`DELETE FROM customer_tokens WHERE customer_id = $1 AND kind = 'password_reset'`, [customerId]); + await client.query('COMMIT'); + } catch (err) { + await client.query('ROLLBACK'); + throw err; + } finally { + client.release(); + } + + const { rows: fresh } = await pool.query(`SELECT * FROM customers WHERE id = $1`, [customerId]); + const sessionToken = await createSession(customerId); + setSessionCookie(res, sessionToken); + res.json(publicCustomer(fresh[0])); +})); + router.post('/login', async (req: Request, res: Response) => { const { email, password } = req.body; const { rows } = await pool.query(`SELECT * FROM customers WHERE email = $1`, [String(email || '').toLowerCase().trim()]); diff --git a/backend/tests/integration/passwordReset.integration.test.ts b/backend/tests/integration/passwordReset.integration.test.ts new file mode 100644 index 0000000..9d6a1b4 --- /dev/null +++ b/backend/tests/integration/passwordReset.integration.test.ts @@ -0,0 +1,207 @@ +import request from 'supertest'; +import app from '../../src/app'; +import { pool } from '../../src/db'; +import { resetDb, closeDb } from './setup/testDb'; + +beforeEach(async () => { + await resetDb(); +}); + +afterAll(async () => { + await pool.end(); + await closeDb(); +}); + +const PASSWORD = 'supersecret123'; + +async function register(email: string) { + const agent = request.agent(app); + const res = await agent.post('/api/customers/register').send({ email, password: PASSWORD }); + expect(res.status).toBe(200); + return agent; +} + +async function latestResetToken(email: string): Promise { + const { rows } = await pool.query( + `SELECT t.token FROM customer_tokens t + JOIN customers c ON c.id = t.customer_id + WHERE c.email = $1 AND t.kind = 'password_reset' + ORDER BY t.created_at DESC LIMIT 1`, + [email] + ); + return rows[0]?.token; +} + +describe('POST /api/customers/request-password-reset', () => { + it('issues a reset token for a known address', async () => { + await register('known@example.com'); + + const res = await request(app).post('/api/customers/request-password-reset').send({ email: 'known@example.com' }); + expect(res.status).toBe(200); + expect(await latestResetToken('known@example.com')).toBeTruthy(); + }); + + it('reports the same success for an unknown address, and issues nothing', async () => { + const res = await request(app) + .post('/api/customers/request-password-reset') + .send({ email: 'nobody@example.com' }); + + // Differing responses would turn this endpoint into an oracle for which + // addresses have accounts. + expect(res.status).toBe(200); + const { rows } = await pool.query(`SELECT COUNT(*)::int AS n FROM customer_tokens WHERE kind = 'password_reset'`); + expect(rows[0].n).toBe(0); + }); + + it('matches the address case-insensitively, as login does', async () => { + await register('mixed@example.com'); + + await request(app).post('/api/customers/request-password-reset').send({ email: 'MiXeD@Example.com ' }); + expect(await latestResetToken('mixed@example.com')).toBeTruthy(); + }); + + it('invalidates an earlier token when a new one is requested', async () => { + await register('twice@example.com'); + + await request(app).post('/api/customers/request-password-reset').send({ email: 'twice@example.com' }); + const first = await latestResetToken('twice@example.com'); + await request(app).post('/api/customers/request-password-reset').send({ email: 'twice@example.com' }); + const second = await latestResetToken('twice@example.com'); + + expect(second).not.toBe(first); + const stale = await request(app) + .post('/api/customers/reset-password') + .send({ token: first, password: 'brandnewpassword' }); + expect(stale.status).toBe(400); + }); + + it('rejects a malformed email without pretending to have sent anything', async () => { + const res = await request(app).post('/api/customers/request-password-reset').send({ email: 'not-an-email' }); + expect(res.status).toBe(400); + }); + + it('rate limits repeated requests for the same address', async () => { + await register('flood@example.com'); + + const statuses: number[] = []; + for (let i = 0; i < 8; i++) { + const res = await request(app).post('/api/customers/request-password-reset').send({ email: 'flood@example.com' }); + statuses.push(res.status); + } + + // Without a limit this endpoint will send unlimited mail to any address. + expect(statuses).toContain(429); + }); +}); + +describe('POST /api/customers/reset-password', () => { + async function requestReset(email: string): Promise { + await request(app).post('/api/customers/request-password-reset').send({ email }); + const token = await latestResetToken(email); + expect(token).toBeTruthy(); + return token as string; + } + + it('sets a new password and rejects the old one', async () => { + await register('change@example.com'); + const token = await requestReset('change@example.com'); + + const res = await request(app).post('/api/customers/reset-password').send({ token, password: 'a-brand-new-password' }); + expect(res.status).toBe(200); + + const oldLogin = await request(app).post('/api/customers/login').send({ email: 'change@example.com', password: PASSWORD }); + expect(oldLogin.status).toBe(401); + + const newLogin = await request(app) + .post('/api/customers/login') + .send({ email: 'change@example.com', password: 'a-brand-new-password' }); + expect(newLogin.status).toBe(200); + }); + + it('signs the customer in on success', async () => { + await register('signedin@example.com'); + const token = await requestReset('signedin@example.com'); + + const agent = request.agent(app); + const res = await agent.post('/api/customers/reset-password').send({ token, password: 'a-brand-new-password' }); + expect(res.status).toBe(200); + + const me = await agent.get('/api/customers/me'); + expect(me.status).toBe(200); + expect(me.body.email).toBe('signedin@example.com'); + }); + + it('terminates sessions established before the reset', async () => { + const oldSession = await register('evict@example.com'); + expect((await oldSession.get('/api/customers/me')).status).toBe(200); + + const token = await requestReset('evict@example.com'); + await request(app).post('/api/customers/reset-password').send({ token, password: 'a-brand-new-password' }); + + // A reset prompted by a compromise has to evict the attacker; leaving a + // 30-day cookie alive would defeat the point. + expect((await oldSession.get('/api/customers/me')).status).toBe(401); + }); + + it('marks the email verified, since the customer received mail at it', async () => { + await register('unverified@example.com'); + const token = await requestReset('unverified@example.com'); + + await request(app).post('/api/customers/reset-password').send({ token, password: 'a-brand-new-password' }); + + const { rows } = await pool.query(`SELECT email_verified FROM customers WHERE email = $1`, ['unverified@example.com']); + expect(rows[0].email_verified).toBe(true); + }); + + it('consumes the token so it cannot be replayed', async () => { + await register('replay@example.com'); + const token = await requestReset('replay@example.com'); + + await request(app).post('/api/customers/reset-password').send({ token, password: 'a-brand-new-password' }); + const second = await request(app).post('/api/customers/reset-password').send({ token, password: 'another-password' }); + expect(second.status).toBe(400); + }); + + it('rejects an expired token', async () => { + await register('expired@example.com'); + const token = await requestReset('expired@example.com'); + await pool.query(`UPDATE customer_tokens SET expires_at = now() - interval '1 minute' WHERE token = $1`, [token]); + + const res = await request(app).post('/api/customers/reset-password').send({ token, password: 'a-brand-new-password' }); + expect(res.status).toBe(400); + }); + + it('refuses a verify_email token, so one kind cannot stand in for another', async () => { + await register('crosskind@example.com'); + const { rows } = await pool.query( + `SELECT t.token FROM customer_tokens t JOIN customers c ON c.id = t.customer_id + WHERE c.email = $1 AND t.kind = 'verify_email'`, + ['crosskind@example.com'] + ); + expect(rows[0].token).toBeTruthy(); + + const res = await request(app) + .post('/api/customers/reset-password') + .send({ token: rows[0].token, password: 'a-brand-new-password' }); + expect(res.status).toBe(400); + }); + + it('rejects an unknown token', async () => { + const res = await request(app) + .post('/api/customers/reset-password') + .send({ token: 'nonsense', password: 'a-brand-new-password' }); + expect(res.status).toBe(400); + }); + + it('enforces the same minimum password length as registration', async () => { + await register('short@example.com'); + const token = await requestReset('short@example.com'); + + const res = await request(app).post('/api/customers/reset-password').send({ token, password: 'short' }); + expect(res.status).toBe(400); + + // A rejected attempt must not burn the token. + const retry = await request(app).post('/api/customers/reset-password').send({ token, password: 'long-enough-password' }); + expect(retry.status).toBe(200); + }); +}); diff --git a/docker-compose.qa.yml b/docker-compose.qa.yml index 0ae7831..1aaad97 100644 --- a/docker-compose.qa.yml +++ b/docker-compose.qa.yml @@ -17,23 +17,23 @@ # QA_DB_PASSWORD — deliberately not named DB_PASSWORD, so pasting the # production stack's variables here does nothing silently. # -# BUILD THE IMAGE BEFORE DEPLOYING THIS STACK. redefined-designs:qa exists only -# on the NAS and is never pushed to a registry, so deploying first makes Compose -# fall back to pulling from Docker Hub and fail with a misleading -# "pull access denied ... repository does not exist or may require docker login". +# The image comes from the Gitea container registry, built by the manual +# "Build QA Image" workflow. Redeploy this stack with Portainer's +# "Pull latest image" toggle ON, or it will keep running the image it already +# has and the redeploy will appear to do nothing. # -# sudo docker build --no-cache -t redefined-designs:qa /volume1/docker/redefined-designs +# Portainer needs registry credentials for gitea.bermudalamb.synology.me once +# (Registries > Add registry, custom, with a Gitea token that has read:package). # -# For the same reason, leave Portainer's "Pull latest image" toggle off. -# `pull_policy: never` below makes a missing image report itself as missing -# rather than as a registry authentication problem. If the Docker Compose -# version on the NAS ever rejects that key, it is safe to delete the line — it -# only improves the error message. +# This replaced a locally-built `redefined-designs:qa` with `pull_policy: never`. +# That arrangement meant the image existed only if someone had remembered to +# build it, which produced two confusing deploy failures: a "pull access denied" +# from Docker Hub when the tag was missing entirely, and a silent stale-image +# deploy when the build had not been rerun. services: redefined-designs-qa: - image: redefined-designs:qa - pull_policy: never + image: gitea.bermudalamb.synology.me/bermudalamb/redefined-designs:qa container_name: redefined-designs-qa-syn environment: - TZ=America/Chicago diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 368a46e..ac486ea 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -19,9 +19,11 @@ }, "devDependencies": { "@playwright/test": "^1.47.0", + "@types/pg": "^8.23.1", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", "@vitejs/plugin-react": "^4.3.1", + "pg": "^8.23.0", "typescript": "^5.5.4", "vite": "^5.4.0" } @@ -1401,6 +1403,28 @@ "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==" }, + "node_modules/@types/node": { + "version": "26.2.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.2.0.tgz", + "integrity": "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/@types/pg": { + "version": "8.23.1", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.23.1.tgz", + "integrity": "sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, "node_modules/@types/prismjs": { "version": "1.26.6", "resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.6.tgz", @@ -3231,6 +3255,103 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, + "node_modules/pg": { + "version": "8.23.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.0.tgz", + "integrity": "sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.14.0", + "pg-pool": "^3.14.0", + "pg-protocol": "^1.16.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.4.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz", + "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz", + "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", + "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.0.tgz", + "integrity": "sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==", + "dev": true, + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -3295,6 +3416,49 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/property-information": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz", @@ -4317,6 +4481,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/string-convert": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/string-convert/-/string-convert-0.2.1.tgz", @@ -4400,6 +4574,13 @@ "node": ">=14.17" } }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "dev": true, + "license": "MIT" + }, "node_modules/unified": { "version": "11.0.5", "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", @@ -4642,6 +4823,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index ce20ea7..8d081e8 100755 --- a/frontend/package.json +++ b/frontend/package.json @@ -8,21 +8,23 @@ "test:e2e": "playwright test" }, "dependencies": { + "@ant-design/icons": "^5.4.0", + "@uiw/react-md-editor": "^4.0.4", + "antd": "^5.20.6", "react": "^18.3.1", "react-dom": "^18.3.1", - "react-router-dom": "^6.26.0", - "antd": "^5.20.6", - "@ant-design/icons": "^5.4.0", "react-markdown": "^9.0.1", - "remark-gfm": "^4.0.0", - "@uiw/react-md-editor": "^4.0.4" + "react-router-dom": "^6.26.0", + "remark-gfm": "^4.0.0" }, "devDependencies": { + "@playwright/test": "^1.47.0", + "@types/pg": "^8.23.1", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", "@vitejs/plugin-react": "^4.3.1", + "pg": "^8.23.0", "typescript": "^5.5.4", - "vite": "^5.4.0", - "@playwright/test": "^1.47.0" + "vite": "^5.4.0" } } diff --git a/frontend/src/customer/ForgotPassword.tsx b/frontend/src/customer/ForgotPassword.tsx new file mode 100644 index 0000000..ca72c66 --- /dev/null +++ b/frontend/src/customer/ForgotPassword.tsx @@ -0,0 +1,76 @@ +import { useState } from 'react'; +import Form from 'antd/es/form'; +import Input from 'antd/es/input'; +import Button from 'antd/es/button'; +import Typography from 'antd/es/typography'; +import Card from 'antd/es/card'; +import Alert from 'antd/es/alert'; +import { Link } from 'react-router-dom'; +import { requestPasswordReset } from './customerApi'; + +const { Title, Paragraph, Text } = Typography; + +export default function ForgotPassword() { + const [sent, setSent] = useState(false); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + + async function onFinish(values: { email: string }) { + setLoading(true); + setError(null); + try { + await requestPasswordReset(values.email); + setSent(true); + } catch (err) { + setError((err as Error).message); + } finally { + setLoading(false); + } + } + + return ( + + Reset your password + + {sent ? ( + <> + {/* Worded so it reveals nothing about whether the address has an + account — the server deliberately answers the same either way. */} + + + Back to sign in + + + ) : ( + <> + + Enter the email address for your account and we'll send you a link to choose a new password. + + {error && } +
+ + + + + + +
+ + Remembered it? Sign in + + + )} +
+ ); +} diff --git a/frontend/src/customer/Login.tsx b/frontend/src/customer/Login.tsx index f76055d..680187a 100755 --- a/frontend/src/customer/Login.tsx +++ b/frontend/src/customer/Login.tsx @@ -43,6 +43,8 @@ export default function Login() { + Forgot password? +
No account yet? Create one
diff --git a/frontend/src/customer/ResetPassword.tsx b/frontend/src/customer/ResetPassword.tsx new file mode 100644 index 0000000..35cf8db --- /dev/null +++ b/frontend/src/customer/ResetPassword.tsx @@ -0,0 +1,97 @@ +import { useState } from 'react'; +import Form from 'antd/es/form'; +import Input from 'antd/es/input'; +import Button from 'antd/es/button'; +import Typography from 'antd/es/typography'; +import Card from 'antd/es/card'; +import Alert from 'antd/es/alert'; +import { Link, useNavigate, useSearchParams } from 'react-router-dom'; +import { resetPassword } from './customerApi'; +import { useCustomerAuth } from './CustomerAuthContext'; + +const { Title, Paragraph } = Typography; + +export default function ResetPassword() { + const [searchParams] = useSearchParams(); + const token = searchParams.get('token'); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + const navigate = useNavigate(); + const { refresh } = useCustomerAuth(); + + // A link without a token can't do anything, so say so rather than showing a + // form that is guaranteed to fail on submit. + if (!token) { + return ( + + Reset your password + + + Request a new link + + + ); + } + + async function onFinish(values: { password: string }) { + setLoading(true); + setError(null); + try { + await resetPassword(token as string, values.password); + // The server signs the customer in as part of the reset, so pick up the + // new session before navigating. + refresh(); + navigate('/account', { replace: true }); + } catch (err) { + setError((err as Error).message); + } finally { + setLoading(false); + } + } + + return ( + + Choose a new password + + Signing in elsewhere will end — you'll stay signed in on this device. + + {error && } +
+ + + + ({ + validator: (_, value) => + !value || getFieldValue('password') === value + ? Promise.resolve() + : Promise.reject(new Error('The passwords do not match')) + }) + ]} + > + + + + + +
+ Back to sign in +
+ ); +} diff --git a/frontend/src/customer/customerApi.ts b/frontend/src/customer/customerApi.ts index 84697a7..1ffc8af 100755 --- a/frontend/src/customer/customerApi.ts +++ b/frontend/src/customer/customerApi.ts @@ -84,3 +84,19 @@ export function deleteMyAccount(): Promise { export function exportMyData(): void { window.location.href = '/api/customers/me/export'; } + +export function requestPasswordReset(email: string): Promise<{ status: string }> { + return fetch('/api/customers/request-password-reset', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email }) + }).then(res => handle<{ status: string }>(res)); +} + +export function resetPassword(token: string, password: string): Promise { + return fetch('/api/customers/reset-password', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token, password }) + }).then(res => handle(res)); +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index f9e4e03..305db59 100755 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -10,6 +10,8 @@ import Register from './customer/Register'; import Account from './customer/Account'; import PrivacyPolicy from './customer/PrivacyPolicy'; import VerifyEmail from './customer/VerifyEmail'; +import ForgotPassword from './customer/ForgotPassword'; +import ResetPassword from './customer/ResetPassword'; import Cart from './cart/Cart'; import { CustomerAuthProvider } from './customer/CustomerAuthContext'; import { CartProvider } from './cart/CartContext'; @@ -72,6 +74,8 @@ function Root() { } /> } /> } /> + } /> + } /> diff --git a/frontend/tests/e2e/password-reset.spec.ts b/frontend/tests/e2e/password-reset.spec.ts new file mode 100644 index 0000000..5af3745 --- /dev/null +++ b/frontend/tests/e2e/password-reset.spec.ts @@ -0,0 +1,145 @@ +import { test, expect, Page } from '@playwright/test'; +import { Client } from 'pg'; + +const PASSWORD = 'supersecret123'; +const NEW_PASSWORD = 'a-brand-new-password'; + +const uniqueEmail = () => `reset-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}@example.com`; + +async function register(page: Page, email: string) { + await page.goto('/register'); + await page.getByRole('textbox', { name: 'Email' }).fill(email); + await page.getByLabel('Password').fill(PASSWORD); + await page.getByRole('button', { name: 'Create account' }).click(); + await expect(page).toHaveURL(/\/account/); +} + +async function logout(page: Page) { + await page.getByRole('button', { name: 'Log out' }).click(); + await expect(page).toHaveURL(/\/$/); +} + +test.describe('Password reset', () => { + test('the login page offers a way to recover a forgotten password', async ({ page }) => { + await page.goto('/login'); + await page.getByRole('link', { name: 'Forgot password?' }).click(); + + await expect(page).toHaveURL(/\/forgot-password/); + await expect(page.getByRole('heading', { name: 'Reset your password' })).toBeVisible(); + }); + + test('requesting a reset confirms without revealing whether the account exists', async ({ page }) => { + await page.goto('/forgot-password'); + await page.getByRole('textbox', { name: 'Email' }).fill('definitely-nobody@example.com'); + await page.getByRole('button', { name: 'Send reset link' }).click(); + + // Identical wording either way; a differing message would make this an + // account-enumeration oracle. + await expect(page.getByText('Check your email')).toBeVisible(); + await expect(page.getByText(/If an account exists/)).toBeVisible(); + }); + + test('a reset link with no token explains itself instead of failing on submit', async ({ page }) => { + await page.goto('/reset-password'); + + await expect(page.getByText('This link is incomplete')).toBeVisible(); + await expect(page.getByRole('button', { name: 'Set new password' })).toHaveCount(0); + }); + + test('rejects a mismatched confirmation before contacting the server', async ({ page }) => { + await page.goto('/reset-password?token=whatever'); + await page.getByLabel('New password', { exact: true }).fill(NEW_PASSWORD); + await page.getByLabel('Confirm new password').fill('something-else-entirely'); + await page.getByRole('button', { name: 'Set new password' }).click(); + + await expect(page.getByText('The passwords do not match')).toBeVisible(); + }); + + test('reports an invalid token rather than appearing to succeed', async ({ page }) => { + await page.goto('/reset-password?token=not-a-real-token'); + await page.getByLabel('New password', { exact: true }).fill(NEW_PASSWORD); + await page.getByLabel('Confirm new password').fill(NEW_PASSWORD); + await page.getByRole('button', { name: 'Set new password' }).click(); + + await expect(page.getByText('invalid or expired token')).toBeVisible(); + await expect(page).toHaveURL(/\/reset-password/); + }); + + test('a customer can reset their password and sign in with the new one', async ({ page, request }) => { + const email = uniqueEmail(); + await register(page, email); + await logout(page); + + // The reset link arrives by email, which the tests can't read. Request the + // reset through the real endpoint, then read the issued token the way the + // customer's mail client would deliver it. + const requested = await request.post('/api/customers/request-password-reset', { data: { email } }); + expect(requested.ok()).toBeTruthy(); + + const token = await readResetToken(email); + await page.goto(`/reset-password?token=${token}`); + await page.getByLabel('New password', { exact: true }).fill(NEW_PASSWORD); + await page.getByLabel('Confirm new password').fill(NEW_PASSWORD); + await page.getByRole('button', { name: 'Set new password' }).click(); + + // The reset signs them in, so they land on the account page. + await expect(page).toHaveURL(/\/account/); + await expect(page.getByText(email)).toBeVisible(); + + // And the new password actually works on a fresh sign-in. + await logout(page); + await page.goto('/login'); + await page.getByRole('textbox', { name: 'Email' }).fill(email); + await page.getByLabel('Password').fill(NEW_PASSWORD); + await page.getByRole('button', { name: 'Log in' }).click(); + await expect(page).toHaveURL(/\/account/); + }); + + test('the old password stops working after a reset', async ({ page, request }) => { + const email = uniqueEmail(); + await register(page, email); + await logout(page); + + await request.post('/api/customers/request-password-reset', { data: { email } }); + const token = await readResetToken(email); + await request.post('/api/customers/reset-password', { data: { token, password: NEW_PASSWORD } }); + + await page.goto('/login'); + await page.getByRole('textbox', { name: 'Email' }).fill(email); + await page.getByLabel('Password').fill(PASSWORD); + await page.getByRole('button', { name: 'Log in' }).click(); + + await expect(page.getByText('invalid email or password')).toBeVisible(); + }); +}); + +// The token is only ever delivered by email, which these tests cannot read. +// +// It is read straight from the database rather than through a helper endpoint: +// an endpoint that returns a password-reset token for an arbitrary address is +// account takeover for every customer if it is ever reachable, and an +// environment gate is a thin thing to stand between that and production. Doing +// it here keeps the capability entirely inside the test process. +async function readResetToken(email: string): Promise { + const client = new Client({ + host: process.env.TEST_PGHOST || 'localhost', + port: parseInt(process.env.TEST_PGPORT || '55432', 10), + user: process.env.TEST_PGUSER || 'redefined_test', + password: process.env.TEST_PGPASSWORD || 'redefined_test', + database: process.env.TEST_PGDATABASE || 'redefined_test' + }); + await client.connect(); + try { + const { rows } = await client.query( + `SELECT t.token FROM customer_tokens t + JOIN customers c ON c.id = t.customer_id + WHERE c.email = $1 AND t.kind = 'password_reset' + ORDER BY t.created_at DESC LIMIT 1`, + [email] + ); + if (!rows.length) throw new Error(`no password_reset token issued for ${email}`); + return rows[0].token as string; + } finally { + await client.end(); + } +}