diff --git a/AGENTS.md b/AGENTS.md index a9d0fe4..f04ba95 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -179,6 +179,12 @@ docker compose start redis # app reconnects on its Verify Redis state through `GET /api/system-info` → `cache` (`driver`, `ready`, `hits`, `misses`, `fallbackOps`, `used_memory_human`, `keys`). +`api.smoketest.js` also locks the auth contract: only `X-Auth-Token` with a session token +authenticates, while `X-Admin-Token`, `Authorization: Bearer` and `ADMIN_PASSWORD` used as a +token must all be rejected with 401. If you change the auth scheme, update this test and the +Auth notes in this file together — a doc that drifts from the code is the failure mode this +guards against. + --- ## Security Checklist (before any change) @@ -187,6 +193,7 @@ Verify Redis state through `GET /api/system-info` → `cache` (`driver`, `ready` - [ ] File I/O через `storage.*`, ключи объектов не выходят за пределы бакета/`uploads/` - [ ] Rate limiter on new public routes - [ ] Admin routes behind `requireAdmin` +- [ ] New auth paths checked against the contract in `api.smoketest.js`, docs updated in the same change - [ ] No secrets in code — only via env vars - [ ] Helmet headers present (already global) - [ ] CORS disabled (no `cors` middleware) diff --git a/api.smoketest.js b/api.smoketest.js index ad07c70..2085ea9 100644 --- a/api.smoketest.js +++ b/api.smoketest.js @@ -11,10 +11,11 @@ function loadEnv() { const BASE = process.env.BASE || 'http://localhost:3003'; -async function api(pathname, { token, method = 'GET', body } = {}) { +async function api(pathname, { token, method = 'GET', body, headers: extra } = {}) { const headers = {}; if (token) headers['X-Auth-Token'] = token; if (body) headers['Content-Type'] = 'application/json'; + Object.assign(headers, extra || {}); const res = await fetch(BASE + pathname, { method, headers, @@ -46,6 +47,28 @@ async function main() { const me1 = await api('/api/auth/me', { token }); ok('auth/me', me1.status === 200 && me1.data.id > 0 && me1.data.is_active === true, { status: me1.status, id: me1.data && me1.data.id }); + // Контракт авторизации. Держим в синхроне с AGENTS.md/README: доступ даёт только + // X-Auth-Token с токеном сессии. Никакой статический токен (в т.ч. ранее + // документированный X-Admin-Token = ADMIN_PASSWORD) доступа не даёт, и + // ADMIN_PASSWORD не является паролем для входа, кроме случая пустой БД, + // где он задаётся через login. + const PROTECTED = '/api/auth/me'; + const ADMIN_ONLY = '/api/users'; + const noAuth = await api(PROTECTED); + ok('auth: защищённый маршрут без токена -> 401', noAuth.status === 401, noAuth.status); + const garbage = await api(PROTECTED, { token: 'deadbeef' }); + ok('auth: мусорный X-Auth-Token -> 401', garbage.status === 401, garbage.status); + const legacy = await api(PROTECTED, { headers: { 'X-Admin-Token': pass } }); + ok('auth: X-Admin-Token не авторизует -> 401', legacy.status === 401, legacy.status); + const bearer = await api(PROTECTED, { headers: { Authorization: 'Bearer ' + token } }); + ok('auth: Authorization Bearer не поддерживается -> 401', bearer.status === 401, bearer.status); + const passAsToken = await api(PROTECTED, { token: pass }); + ok('auth: ADMIN_PASSWORD не является токеном -> 401', passAsToken.status === 401, passAsToken.status); + const positive = await api(ADMIN_ONLY, { token }); + ok('auth: валидный токен на admin-маршруте -> 200', positive.status === 200, positive.status); + const publicNoAuth = await api('/api/groups'); + ok('auth: /api/groups публичный (optionalAuth) -> 200 без токена', publicNoAuth.status === 200, publicNoAuth.status); + const groups = await api('/api/groups', { token }); ok('groups', groups.status === 200 && Array.isArray(groups.data), groups.status);