feat(mam-api): POST /auth/login + redirect-loop regression test
This commit is contained in:
parent
c9f9698b58
commit
f8b6f7d5ef
2 changed files with 110 additions and 1 deletions
|
|
@ -1,7 +1,7 @@
|
|||
import express from 'express';
|
||||
import pool from '../db/pool.js';
|
||||
import { DEV_USER_ID } from '../middleware/auth.js';
|
||||
import { hashPassword } from '../auth/passwords.js';
|
||||
import { hashPassword, comparePassword } from '../auth/passwords.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
|
|
@ -59,5 +59,39 @@ router.post('/setup', async (req, res, next) => {
|
|||
}
|
||||
});
|
||||
|
||||
// POST /api/v1/auth/login — authenticate an existing user by username + password.
|
||||
router.post('/login', async (req, res, next) => {
|
||||
try {
|
||||
const { username, password } = req.body || {};
|
||||
if (!username || !password) return res.status(401).json({ error: 'invalid credentials' });
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`SELECT id, username, display_name, password_hash FROM users WHERE username = $1 AND id <> $2`,
|
||||
[username.trim(), DEV_USER_ID]
|
||||
);
|
||||
if (rows.length === 0) {
|
||||
// Still hash the supplied password against a dummy to keep response time uniform.
|
||||
await comparePassword(password, '$2b$12$dummyhashthatwillalwaysfailtocomparexxxxxxxxxxxxxxxxxxxx');
|
||||
return res.status(401).json({ error: 'invalid credentials' });
|
||||
}
|
||||
const user = rows[0];
|
||||
if (!(await comparePassword(password, user.password_hash))) {
|
||||
return res.status(401).json({ error: 'invalid credentials' });
|
||||
}
|
||||
|
||||
req.session.user_id = user.id;
|
||||
req.session.first_seen_at = Date.now();
|
||||
req.session.last_seen_at = Date.now();
|
||||
// The critical line — wait for the row to land in `sessions` before responding.
|
||||
// Without this, the SPA's next request races the store write, hits 401, and
|
||||
// the prior bounce-to-login logic produced an infinite loop.
|
||||
await new Promise((resolve, reject) => req.session.save(err => err ? reject(err) : resolve()));
|
||||
|
||||
await pool.query(`UPDATE users SET last_login_at = NOW() WHERE id = $1`, [user.id]).catch(() => {});
|
||||
|
||||
res.json({ user: { id: user.id, username: user.username, display_name: user.display_name } });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
export default router;
|
||||
export { realUserCount };
|
||||
|
|
|
|||
|
|
@ -4,6 +4,9 @@ import { isTestDbConfigured, setupTestDb } from '../helpers/setup-db.js';
|
|||
import express from 'express';
|
||||
import session from 'express-session';
|
||||
import authRouter from '../../src/routes/auth.js';
|
||||
import { hashPassword } from '../../src/auth/passwords.js';
|
||||
import { comparePassword } from '../../src/auth/passwords.js';
|
||||
import { requireAuth } from '../../src/middleware/auth.js';
|
||||
|
||||
async function appWithAuth(pool) {
|
||||
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
|
||||
|
|
@ -102,3 +105,75 @@ test('POST /auth/setup rejects passwords shorter than 12 chars', { skip: !isTest
|
|||
assert.equal(res.status, 400);
|
||||
} finally { await close(); await pool.end(); }
|
||||
});
|
||||
|
||||
async function appWithSessionAndMe(pool) {
|
||||
// Same as appWithSession but also mounts a tiny /me endpoint behind requireAuth
|
||||
// so we can exercise the round-trip: login → cookie sent → /me 200.
|
||||
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
|
||||
process.env.AUTH_ENABLED = 'true';
|
||||
const ConnectPg = (await import('connect-pg-simple')).default(session);
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(session({
|
||||
store: new ConnectPg({ pool, tableName: 'sessions' }),
|
||||
secret: 'test', name: 'dragonflight.sid',
|
||||
cookie: { httpOnly: true, sameSite: 'lax', secure: false, maxAge: 8 * 3600 * 1000 },
|
||||
rolling: false, resave: false, saveUninitialized: false,
|
||||
}));
|
||||
app.use('/api/v1/auth', authRouter);
|
||||
app.get('/api/v1/protected/me', requireAuth, (req, res) => res.json({ user: req.user }));
|
||||
return new Promise(r => {
|
||||
const srv = app.listen(0, '127.0.0.1', () => {
|
||||
r({ baseUrl: 'http://127.0.0.1:' + srv.address().port, close: () => new Promise(rs => srv.close(rs)) });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test('POST /auth/login with valid creds → 200 + cookie, and the cookie unlocks subsequent requests (regression: redirect loop)', { skip: !isTestDbConfigured() && 'TEST_DATABASE_URL not set' }, async () => {
|
||||
const pool = await setupTestDb();
|
||||
const hash = await hashPassword('correct-horse-battery');
|
||||
await pool.query(`INSERT INTO users (username, password_hash, display_name) VALUES ('alice', $1, 'Alice')`, [hash]);
|
||||
const { baseUrl, close } = await appWithSessionAndMe(pool);
|
||||
try {
|
||||
// 1. Login.
|
||||
const loginRes = await fetch(baseUrl + '/api/v1/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username: 'alice', password: 'correct-horse-battery' }),
|
||||
});
|
||||
assert.equal(loginRes.status, 200);
|
||||
const setCookie = loginRes.headers.get('set-cookie');
|
||||
assert.match(setCookie || '', /dragonflight\.sid=/, 'expected Set-Cookie with dragonflight.sid');
|
||||
|
||||
// 2. The SAME cookie must unlock the next request. This is the bug that
|
||||
// produced the original redirect loop — login returned 200 but no cookie
|
||||
// was persisted, so the next request 401'd and the SPA bounced.
|
||||
const meRes = await fetch(baseUrl + '/api/v1/protected/me', {
|
||||
headers: { cookie: setCookie.split(';')[0] },
|
||||
});
|
||||
assert.equal(meRes.status, 200, 'POST /login returned 200 but the cookie did not unlock /me — this is the regression');
|
||||
assert.equal((await meRes.json()).user.username, 'alice');
|
||||
} finally { await close(); await pool.end(); }
|
||||
});
|
||||
|
||||
test('POST /auth/login with wrong password → 401 + generic message (no enumeration)', { skip: !isTestDbConfigured() && 'TEST_DATABASE_URL not set' }, async () => {
|
||||
const pool = await setupTestDb();
|
||||
const hash = await hashPassword('correct-horse-battery');
|
||||
await pool.query(`INSERT INTO users (username, password_hash) VALUES ('alice', $1)`, [hash]);
|
||||
const { baseUrl, close } = await appWithSessionAndMe(pool);
|
||||
try {
|
||||
const r1 = await fetch(baseUrl + '/api/v1/auth/login', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username: 'alice', password: 'wrong' }),
|
||||
});
|
||||
const r2 = await fetch(baseUrl + '/api/v1/auth/login', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username: 'nobody', password: 'whatever-long-enough' }),
|
||||
});
|
||||
assert.equal(r1.status, 401);
|
||||
assert.equal(r2.status, 401);
|
||||
const e1 = (await r1.json()).error, e2 = (await r2.json()).error;
|
||||
assert.equal(e1, 'invalid credentials');
|
||||
assert.equal(e2, 'invalid credentials'); // identical message — no enumeration
|
||||
} finally { await close(); await pool.end(); }
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue