auth: replace UUID-based login with JWT credential verification
Login now requires login/email + password verified against DB via /api/auth/login. Only approved registrations can access the app. Signal endpoint accepts JWT Bearer tokens alongside legacy api_key auth. Old UUID-only registration flow removed from frontend. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
1adcabf3a6
commit
04f7bd79e2
8 changed files with 173 additions and 128 deletions
|
|
@ -18,6 +18,7 @@ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||||
|
|
||||||
from backend import config, db, push, telegram
|
from backend import config, db, push, telegram
|
||||||
from backend.middleware import (
|
from backend.middleware import (
|
||||||
|
_verify_jwt_token,
|
||||||
create_auth_token,
|
create_auth_token,
|
||||||
rate_limit_auth_login,
|
rate_limit_auth_login,
|
||||||
rate_limit_auth_register,
|
rate_limit_auth_register,
|
||||||
|
|
@ -176,13 +177,36 @@ async def signal(
|
||||||
) -> SignalResponse:
|
) -> SignalResponse:
|
||||||
if credentials is None:
|
if credentials is None:
|
||||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||||
key_hash = _hash_api_key(credentials.credentials)
|
|
||||||
stored_hash = await db.get_api_key_hash_by_uuid(body.user_id)
|
|
||||||
if stored_hash is None or not secrets.compare_digest(key_hash, stored_hash):
|
|
||||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
|
||||||
|
|
||||||
if await db.is_user_blocked(body.user_id):
|
user_identifier: str = ""
|
||||||
raise HTTPException(status_code=403, detail="User is blocked")
|
user_name: str = ""
|
||||||
|
|
||||||
|
# Try JWT auth first (new registration flow)
|
||||||
|
jwt_payload = None
|
||||||
|
try:
|
||||||
|
jwt_payload = _verify_jwt_token(credentials.credentials)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if jwt_payload is not None:
|
||||||
|
reg_id = int(jwt_payload["sub"])
|
||||||
|
reg = await db.get_registration(reg_id)
|
||||||
|
if reg is None or reg["status"] != "approved":
|
||||||
|
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||||
|
user_identifier = reg["login"]
|
||||||
|
user_name = reg["login"]
|
||||||
|
else:
|
||||||
|
# Legacy api_key auth
|
||||||
|
if not body.user_id:
|
||||||
|
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||||
|
key_hash = _hash_api_key(credentials.credentials)
|
||||||
|
stored_hash = await db.get_api_key_hash_by_uuid(body.user_id)
|
||||||
|
if stored_hash is None or not secrets.compare_digest(key_hash, stored_hash):
|
||||||
|
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||||
|
if await db.is_user_blocked(body.user_id):
|
||||||
|
raise HTTPException(status_code=403, detail="User is blocked")
|
||||||
|
user_identifier = body.user_id
|
||||||
|
user_name = await db.get_user_name(body.user_id) or body.user_id[:8]
|
||||||
|
|
||||||
geo = body.geo
|
geo = body.geo
|
||||||
lat = geo.lat if geo else None
|
lat = geo.lat if geo else None
|
||||||
|
|
@ -190,23 +214,21 @@ async def signal(
|
||||||
accuracy = geo.accuracy if geo else None
|
accuracy = geo.accuracy if geo else None
|
||||||
|
|
||||||
signal_id = await db.save_signal(
|
signal_id = await db.save_signal(
|
||||||
user_uuid=body.user_id,
|
user_uuid=user_identifier,
|
||||||
timestamp=body.timestamp,
|
timestamp=body.timestamp,
|
||||||
lat=lat,
|
lat=lat,
|
||||||
lon=lon,
|
lon=lon,
|
||||||
accuracy=accuracy,
|
accuracy=accuracy,
|
||||||
)
|
)
|
||||||
|
|
||||||
user_name = await db.get_user_name(body.user_id)
|
|
||||||
ts = datetime.fromtimestamp(body.timestamp / 1000, tz=timezone.utc)
|
ts = datetime.fromtimestamp(body.timestamp / 1000, tz=timezone.utc)
|
||||||
name = user_name or body.user_id[:8]
|
|
||||||
geo_info = (
|
geo_info = (
|
||||||
f"📍 {lat}, {lon} (±{accuracy}м)"
|
f"📍 {lat}, {lon} (±{accuracy}м)"
|
||||||
if geo
|
if geo
|
||||||
else "Без геолокации"
|
else "Без геолокации"
|
||||||
)
|
)
|
||||||
text = (
|
text = (
|
||||||
f"🚨 Сигнал от {name}\n"
|
f"🚨 Сигнал от {user_name}\n"
|
||||||
f"⏰ {ts.strftime('%H:%M:%S')} UTC\n"
|
f"⏰ {ts.strftime('%H:%M:%S')} UTC\n"
|
||||||
f"{geo_info}"
|
f"{geo_info}"
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ class GeoData(BaseModel):
|
||||||
|
|
||||||
|
|
||||||
class SignalRequest(BaseModel):
|
class SignalRequest(BaseModel):
|
||||||
user_id: str = Field(..., pattern=r'^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$')
|
user_id: Optional[str] = None # UUID for legacy api_key auth; omit for JWT auth
|
||||||
timestamp: int = Field(..., gt=0)
|
timestamp: int = Field(..., gt=0)
|
||||||
geo: Optional[GeoData] = None
|
geo: Optional[GeoData] = None
|
||||||
|
|
||||||
|
|
|
||||||
133
frontend/app.js
133
frontend/app.js
|
|
@ -39,31 +39,26 @@ function _initStorage() {
|
||||||
|
|
||||||
// ========== User identity ==========
|
// ========== User identity ==========
|
||||||
|
|
||||||
function _getOrCreateUserId() {
|
|
||||||
let id = _storage.getItem('baton_user_id');
|
|
||||||
if (!id) {
|
|
||||||
id = crypto.randomUUID();
|
|
||||||
_storage.setItem('baton_user_id', id);
|
|
||||||
}
|
|
||||||
return id;
|
|
||||||
}
|
|
||||||
|
|
||||||
function _isRegistered() {
|
function _isRegistered() {
|
||||||
return _storage.getItem('baton_registered') === '1';
|
return !!_storage.getItem('baton_auth_token');
|
||||||
}
|
}
|
||||||
|
|
||||||
function _getUserName() {
|
function _getUserName() {
|
||||||
return _storage.getItem('baton_user_name') || '';
|
return _storage.getItem('baton_login') || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function _getApiKey() {
|
function _getAuthToken() {
|
||||||
return _storage.getItem('baton_api_key') || '';
|
return _storage.getItem('baton_auth_token') || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function _saveRegistration(name, apiKey) {
|
function _saveAuth(token, login) {
|
||||||
_storage.setItem('baton_user_name', name);
|
_storage.setItem('baton_auth_token', token);
|
||||||
_storage.setItem('baton_registered', '1');
|
_storage.setItem('baton_login', login);
|
||||||
if (apiKey) _storage.setItem('baton_api_key', apiKey);
|
}
|
||||||
|
|
||||||
|
function _clearAuth() {
|
||||||
|
_storage.removeItem('baton_auth_token');
|
||||||
|
_storage.removeItem('baton_login');
|
||||||
}
|
}
|
||||||
|
|
||||||
function _getInitials(name) {
|
function _getInitials(name) {
|
||||||
|
|
@ -100,6 +95,14 @@ function _setRegStatus(msg, cls) {
|
||||||
el.hidden = !msg;
|
el.hidden = !msg;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function _setLoginStatus(msg, cls) {
|
||||||
|
const el = document.getElementById('login-status');
|
||||||
|
if (!el) return;
|
||||||
|
el.textContent = msg;
|
||||||
|
el.className = 'reg-status' + (cls ? ' reg-status--' + cls : '');
|
||||||
|
el.hidden = !msg;
|
||||||
|
}
|
||||||
|
|
||||||
function _showView(id) {
|
function _showView(id) {
|
||||||
['view-login', 'view-register'].forEach((vid) => {
|
['view-login', 'view-register'].forEach((vid) => {
|
||||||
const el = document.getElementById(vid);
|
const el = document.getElementById(vid);
|
||||||
|
|
@ -157,23 +160,38 @@ function _getGeo() {
|
||||||
|
|
||||||
// ========== Handlers ==========
|
// ========== Handlers ==========
|
||||||
|
|
||||||
async function _handleRegister() {
|
async function _handleLogin() {
|
||||||
const input = document.getElementById('name-input');
|
const loginInput = document.getElementById('login-input');
|
||||||
const btn = document.getElementById('btn-confirm');
|
const passInput = document.getElementById('login-password');
|
||||||
const name = input.value.trim();
|
const btn = document.getElementById('btn-login');
|
||||||
if (!name) return;
|
const login = loginInput.value.trim();
|
||||||
|
const password = passInput.value;
|
||||||
|
if (!login || !password) return;
|
||||||
|
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
_setStatus('', '');
|
_setLoginStatus('', '');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const uuid = _getOrCreateUserId();
|
const data = await _apiPost('/api/auth/login', {
|
||||||
const data = await _apiPost('/api/register', { uuid, name });
|
login_or_email: login,
|
||||||
_saveRegistration(name, data.api_key);
|
password: password,
|
||||||
|
});
|
||||||
|
_saveAuth(data.token, data.login);
|
||||||
|
passInput.value = '';
|
||||||
_updateUserAvatar();
|
_updateUserAvatar();
|
||||||
_showMain();
|
_showMain();
|
||||||
} catch (_) {
|
} catch (err) {
|
||||||
_setStatus('Error. Please try again.', 'error');
|
let msg = 'Ошибка входа. Попробуйте ещё раз.';
|
||||||
|
if (err && err.message) {
|
||||||
|
const colonIdx = err.message.indexOf(': ');
|
||||||
|
if (colonIdx !== -1) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(err.message.slice(colonIdx + 2));
|
||||||
|
if (parsed.detail) msg = parsed.detail;
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_setLoginStatus(msg, 'error');
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -186,9 +204,15 @@ function _setSosState(state) {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function _handleSignal() {
|
async function _handleSignal() {
|
||||||
// v1: no offline queue — show error and return (decision #1019)
|
|
||||||
if (!navigator.onLine) {
|
if (!navigator.onLine) {
|
||||||
_setStatus('No connection. Check your network and try again.', 'error');
|
_setStatus('Нет соединения. Проверьте сеть и попробуйте снова.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = _getAuthToken();
|
||||||
|
if (!token) {
|
||||||
|
_clearAuth();
|
||||||
|
_showOnboarding();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -197,16 +221,13 @@ async function _handleSignal() {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const geo = await _getGeo();
|
const geo = await _getGeo();
|
||||||
const uuid = _getOrCreateUserId();
|
const body = { timestamp: Date.now() };
|
||||||
const body = { user_id: uuid, timestamp: Date.now() };
|
|
||||||
if (geo) body.geo = geo;
|
if (geo) body.geo = geo;
|
||||||
|
|
||||||
const apiKey = _getApiKey();
|
await _apiPost('/api/signal', body, { Authorization: 'Bearer ' + token });
|
||||||
const authHeaders = apiKey ? { Authorization: 'Bearer ' + apiKey } : {};
|
|
||||||
await _apiPost('/api/signal', body, authHeaders);
|
|
||||||
|
|
||||||
_setSosState('success');
|
_setSosState('success');
|
||||||
_setStatus('Signal sent!', 'success');
|
_setStatus('Сигнал отправлен!', 'success');
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
_setSosState('default');
|
_setSosState('default');
|
||||||
_setStatus('', '');
|
_setStatus('', '');
|
||||||
|
|
@ -214,9 +235,11 @@ async function _handleSignal() {
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
_setSosState('default');
|
_setSosState('default');
|
||||||
if (err && err.status === 401) {
|
if (err && err.status === 401) {
|
||||||
_setStatus('Session expired or key is invalid. Please re-register.', 'error');
|
_clearAuth();
|
||||||
|
_setStatus('Сессия истекла. Войдите заново.', 'error');
|
||||||
|
setTimeout(() => _showOnboarding(), 1500);
|
||||||
} else {
|
} else {
|
||||||
_setStatus('Error sending. Try again.', 'error');
|
_setStatus('Ошибка отправки. Попробуйте ещё раз.', 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -227,28 +250,36 @@ function _showOnboarding() {
|
||||||
_showScreen('screen-onboarding');
|
_showScreen('screen-onboarding');
|
||||||
_showView('view-login');
|
_showView('view-login');
|
||||||
|
|
||||||
const input = document.getElementById('name-input');
|
const loginInput = document.getElementById('login-input');
|
||||||
const btn = document.getElementById('btn-confirm');
|
const passInput = document.getElementById('login-password');
|
||||||
|
const btnLogin = document.getElementById('btn-login');
|
||||||
|
|
||||||
input.addEventListener('input', () => {
|
function _updateLoginBtn() {
|
||||||
btn.disabled = input.value.trim().length === 0;
|
btnLogin.disabled = !loginInput.value.trim() || !passInput.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
loginInput.addEventListener('input', _updateLoginBtn);
|
||||||
|
passInput.addEventListener('input', _updateLoginBtn);
|
||||||
|
passInput.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter' && !btnLogin.disabled) _handleLogin();
|
||||||
});
|
});
|
||||||
input.addEventListener('keydown', (e) => {
|
btnLogin.addEventListener('click', _handleLogin);
|
||||||
if (e.key === 'Enter' && !btn.disabled) _handleRegister();
|
|
||||||
});
|
|
||||||
btn.addEventListener('click', _handleRegister);
|
|
||||||
|
|
||||||
const btnToRegister = document.getElementById('btn-switch-to-register');
|
const btnToRegister = document.getElementById('btn-switch-to-register');
|
||||||
if (btnToRegister) {
|
if (btnToRegister) {
|
||||||
btnToRegister.addEventListener('click', () => {
|
btnToRegister.addEventListener('click', () => {
|
||||||
_setRegStatus('', '');
|
_setRegStatus('', '');
|
||||||
|
_setLoginStatus('', '');
|
||||||
_showView('view-register');
|
_showView('view-register');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const btnToLogin = document.getElementById('btn-switch-to-login');
|
const btnToLogin = document.getElementById('btn-switch-to-login');
|
||||||
if (btnToLogin) {
|
if (btnToLogin) {
|
||||||
btnToLogin.addEventListener('click', () => _showView('view-login'));
|
btnToLogin.addEventListener('click', () => {
|
||||||
|
_setLoginStatus('', '');
|
||||||
|
_showView('view-login');
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const btnRegister = document.getElementById('btn-register');
|
const btnRegister = document.getElementById('btn-register');
|
||||||
|
|
@ -403,11 +434,7 @@ async function _handleSignUp() {
|
||||||
function _init() {
|
function _init() {
|
||||||
_initStorage();
|
_initStorage();
|
||||||
|
|
||||||
// Pre-generate and persist UUID on first visit (per arch spec flow)
|
// Private mode graceful degradation (decision #1041)
|
||||||
_getOrCreateUserId();
|
|
||||||
|
|
||||||
// Private mode graceful degradation (decision #1041):
|
|
||||||
// show inline banner with explicit action guidance when localStorage is unavailable
|
|
||||||
if (_storageType !== 'local') {
|
if (_storageType !== 'local') {
|
||||||
const banner = document.getElementById('private-mode-banner');
|
const banner = document.getElementById('private-mode-banner');
|
||||||
if (banner) banner.hidden = false;
|
if (banner) banner.hidden = false;
|
||||||
|
|
@ -418,7 +445,7 @@ function _init() {
|
||||||
window.addEventListener('online', _updateNetworkIndicator);
|
window.addEventListener('online', _updateNetworkIndicator);
|
||||||
window.addEventListener('offline', _updateNetworkIndicator);
|
window.addEventListener('offline', _updateNetworkIndicator);
|
||||||
|
|
||||||
// Route to correct screen
|
// Route to correct screen based on JWT token presence
|
||||||
if (_isRegistered()) {
|
if (_isRegistered()) {
|
||||||
_showMain();
|
_showMain();
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -37,23 +37,32 @@
|
||||||
<!-- Onboarding screen: shown on first visit (no UUID registered yet) -->
|
<!-- Onboarding screen: shown on first visit (no UUID registered yet) -->
|
||||||
<div id="screen-onboarding" class="screen" role="main" hidden>
|
<div id="screen-onboarding" class="screen" role="main" hidden>
|
||||||
|
|
||||||
<!-- View: name entry (existing onboarding) -->
|
<!-- View: login with credentials -->
|
||||||
<div class="screen-content" id="view-login">
|
<div class="screen-content" id="view-login">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="name-input"
|
id="login-input"
|
||||||
class="name-input"
|
class="name-input"
|
||||||
placeholder="Your name"
|
placeholder="Логин или email"
|
||||||
maxlength="100"
|
maxlength="255"
|
||||||
autocomplete="name"
|
autocomplete="username"
|
||||||
autocorrect="off"
|
autocorrect="off"
|
||||||
autocapitalize="words"
|
autocapitalize="none"
|
||||||
spellcheck="false"
|
spellcheck="false"
|
||||||
aria-label="Your name"
|
aria-label="Логин или email"
|
||||||
>
|
>
|
||||||
<button type="button" id="btn-confirm" class="btn-confirm" disabled>
|
<input
|
||||||
Confirm
|
type="password"
|
||||||
|
id="login-password"
|
||||||
|
class="name-input"
|
||||||
|
placeholder="Пароль"
|
||||||
|
autocomplete="current-password"
|
||||||
|
aria-label="Пароль"
|
||||||
|
>
|
||||||
|
<button type="button" id="btn-login" class="btn-confirm" disabled>
|
||||||
|
Войти
|
||||||
</button>
|
</button>
|
||||||
|
<div id="login-status" class="reg-status" hidden></div>
|
||||||
<button type="button" id="btn-switch-to-register" class="btn-link">
|
<button type="button" id="btn-switch-to-register" class="btn-link">
|
||||||
Зарегистрироваться
|
Зарегистрироваться
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -222,9 +222,9 @@ def test_html_loads_app_js() -> None:
|
||||||
assert "/app.js" in _html()
|
assert "/app.js" in _html()
|
||||||
|
|
||||||
|
|
||||||
def test_html_has_name_input() -> None:
|
def test_html_has_login_input() -> None:
|
||||||
"""index.html must have name input field for onboarding."""
|
"""index.html must have login input field for onboarding."""
|
||||||
assert 'id="name-input"' in _html()
|
assert 'id="login-input"' in _html()
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -316,31 +316,19 @@ def _app_js() -> str:
|
||||||
return (FRONTEND / "app.js").read_text(encoding="utf-8")
|
return (FRONTEND / "app.js").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
def test_app_uses_crypto_random_uuid() -> None:
|
def test_app_posts_to_auth_login() -> None:
|
||||||
"""app.js must generate UUID via crypto.randomUUID()."""
|
"""app.js must send POST to /api/auth/login during login."""
|
||||||
assert "crypto.randomUUID()" in _app_js()
|
assert "/api/auth/login" in _app_js()
|
||||||
|
|
||||||
|
|
||||||
def test_app_posts_to_api_register() -> None:
|
def test_app_posts_to_auth_register() -> None:
|
||||||
"""app.js must send POST to /api/register during onboarding."""
|
"""app.js must send POST to /api/auth/register during registration."""
|
||||||
assert "/api/register" in _app_js()
|
assert "/api/auth/register" in _app_js()
|
||||||
|
|
||||||
|
|
||||||
def test_app_register_sends_uuid() -> None:
|
def test_app_stores_auth_token() -> None:
|
||||||
"""app.js must include uuid in the /api/register request body."""
|
"""app.js must persist JWT token to storage."""
|
||||||
app = _app_js()
|
assert "baton_auth_token" in _app_js()
|
||||||
# The register call must include uuid in the payload
|
|
||||||
register_section = re.search(
|
|
||||||
r"_apiPost\(['\"]\/api\/register['\"].*?\)", app, re.DOTALL
|
|
||||||
)
|
|
||||||
assert register_section, "No _apiPost('/api/register') call found"
|
|
||||||
assert "uuid" in register_section.group(0), \
|
|
||||||
"uuid not included in /api/register call"
|
|
||||||
|
|
||||||
|
|
||||||
def test_app_uuid_saved_to_storage() -> None:
|
|
||||||
"""app.js must persist UUID to storage (baton_user_id key)."""
|
|
||||||
assert "baton_user_id" in _app_js()
|
|
||||||
assert "setItem" in _app_js()
|
assert "setItem" in _app_js()
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -434,16 +422,14 @@ def test_app_posts_to_api_signal() -> None:
|
||||||
assert "/api/signal" in _app_js()
|
assert "/api/signal" in _app_js()
|
||||||
|
|
||||||
|
|
||||||
def test_app_signal_sends_user_id() -> None:
|
def test_app_signal_sends_auth_header() -> None:
|
||||||
"""app.js must include user_id (UUID) in the /api/signal request body."""
|
"""app.js must include Authorization Bearer header in /api/signal request."""
|
||||||
app = _app_js()
|
app = _app_js()
|
||||||
# The signal body may be built in a variable before passing to _apiPost
|
|
||||||
# Look for user_id key in the context around /api/signal
|
|
||||||
signal_area = re.search(
|
signal_area = re.search(
|
||||||
r"user_id.*?_apiPost\(['\"]\/api\/signal", app, re.DOTALL
|
r"_apiPost\(['\"]\/api\/signal['\"].*Authorization.*Bearer", app, re.DOTALL
|
||||||
)
|
)
|
||||||
assert signal_area, \
|
assert signal_area, \
|
||||||
"user_id must be set in the request body before calling _apiPost('/api/signal')"
|
"Authorization Bearer header must be set in _apiPost('/api/signal') call"
|
||||||
|
|
||||||
|
|
||||||
def test_app_sos_button_click_calls_handle_signal() -> None:
|
def test_app_sos_button_click_calls_handle_signal() -> None:
|
||||||
|
|
@ -456,15 +442,15 @@ def test_app_sos_button_click_calls_handle_signal() -> None:
|
||||||
"btn-sos must be connected to _handleSignal"
|
"btn-sos must be connected to _handleSignal"
|
||||||
|
|
||||||
|
|
||||||
def test_app_signal_uses_uuid_from_storage() -> None:
|
def test_app_signal_uses_token_from_storage() -> None:
|
||||||
"""app.js must retrieve UUID from storage (_getOrCreateUserId) before sending signal."""
|
"""app.js must retrieve auth token from storage before sending signal."""
|
||||||
app = _app_js()
|
app = _app_js()
|
||||||
handle_signal = re.search(
|
handle_signal = re.search(
|
||||||
r"async function _handleSignal\(\).*?^}", app, re.MULTILINE | re.DOTALL
|
r"async function _handleSignal\(\).*?^}", app, re.MULTILINE | re.DOTALL
|
||||||
)
|
)
|
||||||
assert handle_signal, "_handleSignal function not found"
|
assert handle_signal, "_handleSignal function not found"
|
||||||
assert "_getOrCreateUserId" in handle_signal.group(0), \
|
assert "_getAuthToken" in handle_signal.group(0), \
|
||||||
"_handleSignal must call _getOrCreateUserId() to get UUID"
|
"_handleSignal must call _getAuthToken() to get JWT token"
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -102,10 +102,10 @@ def test_register_request_rejects_old_placeholder_uuid(bad_uuid: str) -> None:
|
||||||
"agg-uuid-001",
|
"agg-uuid-001",
|
||||||
"create-uuid-001",
|
"create-uuid-001",
|
||||||
])
|
])
|
||||||
def test_signal_request_rejects_old_placeholder_uuid(bad_uuid: str) -> None:
|
def test_signal_request_accepts_any_user_id_string(bad_uuid: str) -> None:
|
||||||
"""SignalRequest.user_id must reject old-style placeholder strings."""
|
"""SignalRequest.user_id is optional (no pattern) — validation is at endpoint level."""
|
||||||
with pytest.raises(ValidationError):
|
req = SignalRequest(user_id=bad_uuid, timestamp=1700000000000)
|
||||||
SignalRequest(user_id=bad_uuid, timestamp=1700000000000)
|
assert req.user_id == bad_uuid
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -152,17 +152,16 @@ def test_register_request_rejects_uuid_v3_version_digit() -> None:
|
||||||
RegisterRequest(uuid="550e8400-e29b-31d4-a716-446655440000", name="Test")
|
RegisterRequest(uuid="550e8400-e29b-31d4-a716-446655440000", name="Test")
|
||||||
|
|
||||||
|
|
||||||
def test_signal_request_rejects_uuid_wrong_variant_bits() -> None:
|
def test_signal_request_accepts_any_variant_bits() -> None:
|
||||||
"""UUID with invalid variant bits (0xxx in fourth group) must be rejected."""
|
"""SignalRequest.user_id is now optional and unvalidated (JWT auth doesn't use it)."""
|
||||||
with pytest.raises(ValidationError):
|
req = SignalRequest(user_id="550e8400-e29b-41d4-0716-446655440000", timestamp=1700000000000)
|
||||||
# fourth group starts with '0' — not 8/9/a/b variant
|
assert req.user_id is not None
|
||||||
SignalRequest(user_id="550e8400-e29b-41d4-0716-446655440000", timestamp=1700000000000)
|
|
||||||
|
|
||||||
|
|
||||||
def test_signal_request_rejects_uuid_wrong_variant_c() -> None:
|
def test_signal_request_without_user_id() -> None:
|
||||||
"""UUID with variant 'c' (1100 bits) must be rejected — only 8/9/a/b allowed."""
|
"""SignalRequest works without user_id (JWT auth mode)."""
|
||||||
with pytest.raises(ValidationError):
|
req = SignalRequest(timestamp=1700000000000)
|
||||||
SignalRequest(user_id="550e8400-e29b-41d4-c716-446655440000", timestamp=1700000000000)
|
assert req.user_id is None
|
||||||
|
|
||||||
|
|
||||||
def test_register_request_accepts_all_valid_v4_variants() -> None:
|
def test_register_request_accepts_all_valid_v4_variants() -> None:
|
||||||
|
|
|
||||||
|
|
@ -123,14 +123,16 @@ def test_signal_request_no_geo():
|
||||||
assert req.geo is None
|
assert req.geo is None
|
||||||
|
|
||||||
|
|
||||||
def test_signal_request_missing_user_id():
|
def test_signal_request_without_user_id():
|
||||||
with pytest.raises(ValidationError):
|
"""user_id is optional (JWT auth sends signals without it)."""
|
||||||
SignalRequest(timestamp=1742478000000) # type: ignore[call-arg]
|
req = SignalRequest(timestamp=1742478000000)
|
||||||
|
assert req.user_id is None
|
||||||
|
|
||||||
|
|
||||||
def test_signal_request_empty_user_id():
|
def test_signal_request_empty_user_id():
|
||||||
with pytest.raises(ValidationError):
|
"""Empty string user_id is accepted (treated as None at endpoint level)."""
|
||||||
SignalRequest(user_id="", timestamp=1742478000000)
|
req = SignalRequest(user_id="", timestamp=1742478000000)
|
||||||
|
assert req.user_id == ""
|
||||||
|
|
||||||
|
|
||||||
def test_signal_request_timestamp_zero():
|
def test_signal_request_timestamp_zero():
|
||||||
|
|
|
||||||
|
|
@ -78,14 +78,14 @@ async def test_signal_without_geo_success():
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_signal_missing_user_id_returns_422():
|
async def test_signal_missing_auth_returns_401():
|
||||||
"""Missing user_id field must return 422."""
|
"""Missing Authorization header must return 401."""
|
||||||
async with make_app_client() as client:
|
async with make_app_client() as client:
|
||||||
resp = await client.post(
|
resp = await client.post(
|
||||||
"/api/signal",
|
"/api/signal",
|
||||||
json={"timestamp": 1742478000000},
|
json={"timestamp": 1742478000000},
|
||||||
)
|
)
|
||||||
assert resp.status_code == 422
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue