Files
redefined-designs/backend/tests/integration/disableCustomer.integration.test.ts
T
bermudalambandClaude Opus 5 13c010ff51
SonarQube Analysis / sonarqube (pull_request) Successful in 3m0s
Tests / backend-unit (pull_request) Successful in 48s
Tests / frontend-e2e (pull_request) Failing after 9m41s
feat(admin): disable and re-enable customer accounts (#33)
Adds customers.disabled_at, admin disable/enable endpoints, a Status
column and toggle on the Customers tab, and enforcement across every
path that authenticates.

Enforcement lives in attachCustomer, which previously validated only the
session token and its expiry and never read the customer row. Register,
login and password reset all mint sessions, so a single check in the
middleware covers every path rather than three separate ones — and it
means an existing rd_session cookie stops working at once instead of at
its 30-day expiry. Disabling also deletes the sessions outright, so
eviction does not wait for the next request.

Disabling releases the items the customer was holding, in the same
transaction. A disabled account cannot check out, so leaving its
reservations would keep one-of-a-kind stock off the storefront for up to
the cart expiry window for no purpose. Guarded on 'reserved' so a sold
item is never resurrected. Re-enabling restores sign-in but does not give
the items back — they may since have sold.

Sign-in returns an explicit 403 rather than a generic credential failure.
That does confirm the address has an account, which sits awkwardly beside
the deliberately non-enumerating reset in #32; the trade was made the
other way because a disabled customer told "invalid email or password"
resets their password, succeeds, is still locked out, and concludes the
site is broken. The check runs only after the password verifies, so it is
not a bulk membership oracle, and /register already reveals existence.

A reset token issued before the disable no longer mints a session, and no
new tokens are issued for a disabled account — while still answering 200,
so that endpoint stays non-enumerating.

Self-service GDPR export and deletion are blocked along with everything
else, so those requests now need servicing by hand. Worth checking the
privacy policy does not promise unconditional self-service.

Also fixes an unrelated bug the e2e run surfaced: the admin inventory
fired a request per keystroke in the price fields with no sequencing, so
an older response could land after a newer one and repaint stale rows.
Only the most recently issued request may now set state.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 10:48:28 -05:00

206 lines
8.0 KiB
TypeScript

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);
const { rows } = await pool.query(`SELECT id FROM customers WHERE email = $1`, [email]);
return { agent, id: rows[0].id as number };
}
async function createItem(name: string) {
const { rows } = await pool.query(
`INSERT INTO items (name, price_cents) VALUES ($1, 1000) RETURNING id`,
[name]
);
return rows[0].id as number;
}
describe('disabling a customer', () => {
it('is reported in the admin customer list', async () => {
const { id } = await register('flag@example.com');
await request(app).post(`/api/admin/customers/${id}/disable`).expect(204);
const res = await request(app).get('/api/admin/customers');
const customer = res.body.find((c: { id: number }) => c.id === id);
expect(customer.disabled_at).toBeTruthy();
});
it('refuses the sign-in with an explicit reason, not a credential failure', async () => {
const { id } = await register('told@example.com');
await request(app).post(`/api/admin/customers/${id}/disable`).expect(204);
const res = await request(app)
.post('/api/customers/login')
.send({ email: 'told@example.com', password: PASSWORD });
// A generic 401 would send them round the password-reset loop forever.
expect(res.status).toBe(403);
expect(res.body.error).toMatch(/disabled/i);
});
it('still refuses a sign-in with the wrong password, without revealing the disable', async () => {
const { id } = await register('wrongpw@example.com');
await request(app).post(`/api/admin/customers/${id}/disable`).expect(204);
const res = await request(app)
.post('/api/customers/login')
.send({ email: 'wrongpw@example.com', password: 'not-the-password' });
expect(res.status).toBe(401);
});
it('kills sessions that already existed', async () => {
const { agent, id } = await register('evicted@example.com');
expect((await agent.get('/api/customers/me')).status).toBe(200);
await request(app).post(`/api/admin/customers/${id}/disable`).expect(204);
// The cookie is still held by the agent; it must stop working immediately
// rather than at its 30-day expiry.
expect((await agent.get('/api/customers/me')).status).toBe(401);
});
it('releases items the customer was holding', async () => {
const itemId = await createItem('Held item');
const { agent, id } = await register('holder@example.com');
expect((await agent.post(`/api/cart/items/${itemId}`)).status).toBe(201);
await request(app).post(`/api/admin/customers/${id}/disable`).expect(204);
const item = await request(app).get(`/api/items/${itemId}`);
expect(item.body.status).toBe('available');
const { rows } = await pool.query(`SELECT COUNT(*)::int AS n FROM cart_items WHERE item_id = $1`, [itemId]);
expect(rows[0].n).toBe(0);
});
it('leaves a released item purchasable by someone else', async () => {
const itemId = await createItem('Freed item');
const { agent: first, id } = await register('first@example.com');
await first.post(`/api/cart/items/${itemId}`);
await request(app).post(`/api/admin/customers/${id}/disable`).expect(204);
const { agent: second } = await register('second@example.com');
expect((await second.post(`/api/cart/items/${itemId}`)).status).toBe(201);
});
it('does not resurrect an item that was already sold', async () => {
const itemId = await createItem('Sold item');
const { agent, id } = await register('sold@example.com');
await agent.post(`/api/cart/items/${itemId}`);
await pool.query(`UPDATE items SET status = 'sold' WHERE id = $1`, [itemId]);
await request(app).post(`/api/admin/customers/${id}/disable`).expect(204);
const { rows } = await pool.query(`SELECT status FROM items WHERE id = $1`, [itemId]);
expect(rows[0].status).toBe('sold');
});
it('blocks the self-service data export and account deletion', async () => {
const { agent, id } = await register('gdpr@example.com');
await request(app).post(`/api/admin/customers/${id}/disable`).expect(204);
expect((await agent.get('/api/customers/me/export')).status).toBe(401);
expect((await agent.delete('/api/customers/me')).status).toBe(401);
});
it('sends no reset mail and issues no token for a disabled account', async () => {
const { id } = await register('noreset@example.com');
await request(app).post(`/api/admin/customers/${id}/disable`).expect(204);
// Still 200, so the endpoint stays non-enumerating.
const res = await request(app)
.post('/api/customers/request-password-reset')
.send({ email: 'noreset@example.com' });
expect(res.status).toBe(200);
const { rows } = await pool.query(
`SELECT COUNT(*)::int AS n FROM customer_tokens WHERE customer_id = $1 AND kind = 'password_reset'`,
[id]
);
expect(rows[0].n).toBe(0);
});
it('refuses to complete a reset whose token predates the disable', async () => {
const { id } = await register('midreset@example.com');
await request(app).post('/api/customers/request-password-reset').send({ email: 'midreset@example.com' });
const { rows } = await pool.query(
`SELECT token FROM customer_tokens WHERE customer_id = $1 AND kind = 'password_reset'`,
[id]
);
const token = rows[0].token;
await request(app).post(`/api/admin/customers/${id}/disable`).expect(204);
const res = await request(app)
.post('/api/customers/reset-password')
.send({ token, password: 'a-brand-new-password' });
expect(res.status).toBe(403);
});
it('rejects registering again with the same address', async () => {
const { id } = await register('reregister@example.com');
await request(app).post(`/api/admin/customers/${id}/disable`).expect(204);
// Otherwise disabling is trivially undone by signing up again.
const res = await request(app)
.post('/api/customers/register')
.send({ email: 'reregister@example.com', password: PASSWORD });
expect(res.status).toBe(409);
});
});
describe('re-enabling a customer', () => {
it('restores sign-in', async () => {
const { id } = await register('back@example.com');
await request(app).post(`/api/admin/customers/${id}/disable`).expect(204);
await request(app).post(`/api/admin/customers/${id}/enable`).expect(204);
const res = await request(app)
.post('/api/customers/login')
.send({ email: 'back@example.com', password: PASSWORD });
expect(res.status).toBe(200);
});
it('clears the disabled timestamp', async () => {
const { id } = await register('cleared@example.com');
await request(app).post(`/api/admin/customers/${id}/disable`).expect(204);
await request(app).post(`/api/admin/customers/${id}/enable`).expect(204);
const res = await request(app).get('/api/admin/customers');
const customer = res.body.find((c: { id: number }) => c.id === id);
expect(customer.disabled_at).toBeNull();
});
it('does not give back the items that were released', async () => {
const itemId = await createItem('Not returned');
const { agent, id } = await register('norestore@example.com');
await agent.post(`/api/cart/items/${itemId}`);
await request(app).post(`/api/admin/customers/${id}/disable`).expect(204);
await request(app).post(`/api/admin/customers/${id}/enable`).expect(204);
const item = await request(app).get(`/api/items/${itemId}`);
expect(item.body.status).toBe('available');
});
it('returns 404 for a customer that does not exist', async () => {
expect((await request(app).post('/api/admin/customers/999999/disable')).status).toBe(404);
expect((await request(app).post('/api/admin/customers/999999/enable')).status).toBe(404);
});
});