Files
kite/backend/tests/test_auth_api.py
T

721 lines
28 KiB
Python

"""
Tests for backend/api/auth.py (Plan 02-02, Task 1).
Covers: register, login (TOTP, backup codes, password_must_change), refresh,
logout, me, change-password, per-account rate limiting, Origin validation,
and CSP headers.
Uses the async_client fixture from conftest.py.
Note on mock Redis: app.state.redis is set to a FakeRedis-like dict-based
mock so that per-account rate limiting can be exercised in isolation.
"""
from __future__ import annotations
import hashlib
import uuid
from datetime import datetime, timezone
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
import pytest_asyncio
from httpx import ASGITransport, AsyncClient
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from db.models import BackupCode, Quota, RefreshToken, User
# ── Helpers ──────────────────────────────────────────────────────────────────
async def _register(async_client, handle="testuser", email="t@example.com", password="ValidPass12!"):
"""Register a user and return the response."""
return await async_client.post(
"/api/auth/register",
json={"handle": handle, "email": email, "password": password},
)
async def _login(async_client, email="t@example.com", password="ValidPass12!", **extra):
body = {"email": email, "password": password}
body.update(extra)
return await async_client.post("/api/auth/login", json=body)
# ── Fake Redis for per-account rate limiting ──────────────────────────────────
class FakeRedis:
"""In-memory fake Redis for testing rate limits and TOTP replay prevention."""
def __init__(self):
self._store: dict = {}
async def get(self, key):
entry = self._store.get(key)
if entry is None:
return None
val, exp = entry
if exp is not None and datetime.now(timezone.utc).timestamp() > exp:
del self._store[key]
return None
return val
async def incr(self, key):
entry = self._store.get(key)
if entry is None:
self._store[key] = (1, None)
return 1
val, exp = entry
new_val = val + 1
self._store[key] = (new_val, exp)
return new_val
async def expire(self, key, seconds):
if key in self._store:
val, _ = self._store[key]
deadline = datetime.now(timezone.utc).timestamp() + seconds
self._store[key] = (val, deadline)
async def set(self, key, value, ex=None):
deadline = None
if ex is not None:
deadline = datetime.now(timezone.utc).timestamp() + ex
self._store[key] = (value, deadline)
async def close(self):
pass
@pytest_asyncio.fixture
async def authed_client(db_session: AsyncSession):
"""Async HTTP test client with DB override AND fake Redis on app.state.redis.
Each test gets a fresh FakeRedis instance (reset state). The slowapi
in-memory rate limiter is also reset between tests so that IP-level
counts from one test don't bleed into the next.
"""
from deps.db import get_db
from main import app
from api.auth import limiter as auth_limiter
app.dependency_overrides[get_db] = lambda: db_session
fake_redis = FakeRedis()
app.state.redis = fake_redis
# Reset slowapi's in-memory storage so previous test IP counters don't
# interfere with the current test's rate limit assertions.
try:
auth_limiter._storage.reset()
except Exception:
try:
# Fallback: clear internal storage dict directly
storage_obj = auth_limiter._storage
if hasattr(storage_obj, "_storage"):
storage_obj._storage.clear()
except Exception:
pass
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
yield c
app.dependency_overrides.clear()
# Reset redis to avoid leaking state between test files
app.state.redis = None
# ── Tests — register ──────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_register_success(authed_client):
"""POST /api/auth/register with valid data returns 201 with id and handle."""
resp = await _register(authed_client)
assert resp.status_code == 201, resp.text
data = resp.json()
assert "id" in data
assert data["handle"] == "testuser"
assert data["email"] == "t@example.com"
@pytest.mark.asyncio
async def test_register_weak_password(authed_client):
"""POST /api/auth/register with a short password returns 422."""
resp = await authed_client.post(
"/api/auth/register",
json={"handle": "u", "email": "u@example.com", "password": "short"},
)
assert resp.status_code == 422
@pytest.mark.asyncio
async def test_register_duplicate_email(authed_client):
"""Registering the same email twice returns 409."""
await _register(authed_client, handle="u1", email="dup@example.com")
resp = await _register(authed_client, handle="u2", email="dup@example.com")
assert resp.status_code == 409
assert "already in use" in resp.json()["detail"].lower()
# ── Tests — login ─────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_login_wrong_password(authed_client):
"""POST /api/auth/login with wrong password returns 401."""
await _register(authed_client)
resp = await _login(authed_client, password="WrongPass99!")
assert resp.status_code == 401
assert "Incorrect email or password" in resp.json()["detail"]
@pytest.mark.asyncio
async def test_login_success(authed_client):
"""Register then login returns 200 with access_token; Set-Cookie has HttpOnly + SameSite."""
await _register(authed_client)
resp = await _login(authed_client)
assert resp.status_code == 200, resp.text
data = resp.json()
assert "access_token" in data
cookie_header = resp.headers.get("set-cookie", "")
assert "HttpOnly" in cookie_header or "httponly" in cookie_header.lower()
assert "SameSite=Strict" in cookie_header or "samesite=strict" in cookie_header.lower()
@pytest.mark.asyncio
async def test_me_requires_auth(authed_client):
"""GET /api/auth/me without Bearer token returns 403 (HTTPBearer default)."""
resp = await authed_client.get("/api/auth/me")
# HTTPBearer raises 403 when no Authorization header present
assert resp.status_code in (401, 403)
@pytest.mark.asyncio
async def test_login_password_must_change(authed_client, db_session: AsyncSession):
"""Login for user with password_must_change=True returns 200 with requires_password_change=True, no Set-Cookie."""
await _register(authed_client, handle="pmcu", email="pmc@example.com")
# Flip the flag in the DB
result = await db_session.execute(select(User).where(User.email == "pmc@example.com"))
user = result.scalar_one()
user.password_must_change = True
await db_session.commit()
resp = await _login(authed_client, email="pmc@example.com")
assert resp.status_code == 200
data = resp.json()
assert data.get("requires_password_change") is True
assert "access_token" not in data
# No Set-Cookie header
assert "set-cookie" not in resp.headers
@pytest.mark.asyncio
async def test_change_password_breach(authed_client):
"""POST /api/auth/change-password with breached new password returns 422."""
await _register(authed_client, handle="cpb", email="cpb@example.com")
login_resp = await _login(authed_client, email="cpb@example.com")
token = login_resp.json()["access_token"]
with patch("services.auth.check_hibp", return_value=True) as mock_hibp:
resp = await authed_client.post(
"/api/auth/change-password",
json={"current_password": "ValidPass12!", "new_password": "StrongNew99!@"},
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 422
assert "breach" in resp.json()["detail"].lower()
@pytest.mark.asyncio
async def test_change_password_wrong_current(authed_client):
"""POST /api/auth/change-password with wrong current_password returns 401."""
await _register(authed_client, handle="cpw", email="cpw@example.com")
login_resp = await _login(authed_client, email="cpw@example.com")
token = login_resp.json()["access_token"]
resp = await authed_client.post(
"/api/auth/change-password",
json={"current_password": "WrongCurrent9!", "new_password": "NewStrong99!@"},
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 401
@pytest.mark.asyncio
async def test_change_password_success(authed_client):
"""POST /api/auth/change-password with correct current + strong new password returns 200."""
await _register(authed_client, handle="cps", email="cps@example.com")
login_resp = await _login(authed_client, email="cps@example.com")
token = login_resp.json()["access_token"]
with patch("services.auth.check_hibp", return_value=False):
resp = await authed_client.post(
"/api/auth/change-password",
json={"current_password": "ValidPass12!", "new_password": "NewStrong99!@"},
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
assert resp.json()["message"] == "Password updated"
# ── Tests — Origin validation middleware ──────────────────────────────────────
@pytest.mark.asyncio
async def test_origin_rejected(authed_client):
"""POST /api/auth/login with a cross-origin Origin header returns 403."""
resp = await authed_client.post(
"/api/auth/login",
json={"email": "x@x.com", "password": "any"},
headers={"Origin": "https://evil.example"},
)
assert resp.status_code == 403
@pytest.mark.asyncio
async def test_origin_allowed(authed_client):
"""POST /api/auth/login with an allowed Origin proceeds to auth (not 403)."""
resp = await authed_client.post(
"/api/auth/login",
json={"email": "nobody@example.com", "password": "any"},
headers={"Origin": "http://localhost:5173"},
)
# Not 403 — may be 401 (wrong credentials) or 200
assert resp.status_code != 403
# ── Tests — per-account rate limiting ────────────────────────────────────────
@pytest.mark.asyncio
async def test_per_account_rate_limit(authed_client):
"""11 consecutive login attempts with the same email returns 429 on the 11th."""
email = "ratelimit@example.com"
for i in range(10):
resp = await authed_client.post(
"/api/auth/login",
json={"email": email, "password": "WrongPass99!"},
)
assert resp.status_code in (401, 200), f"Unexpected {resp.status_code} on attempt {i+1}"
# 11th attempt should be rate limited
resp = await authed_client.post(
"/api/auth/login",
json={"email": email, "password": "WrongPass99!"},
)
assert resp.status_code == 429, f"Expected 429 on 11th attempt, got {resp.status_code}: {resp.text}"
# ── Tests — backup codes ──────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_login_backup_code_success(authed_client, db_session: AsyncSession):
"""Login with a valid backup code returns 200 + access_token and invalidates the code."""
await _register(authed_client, handle="bcu", email="bcu@example.com")
result = await db_session.execute(select(User).where(User.email == "bcu@example.com"))
user = result.scalar_one()
# Enable TOTP on the user (so the backup code path is exercised)
user.totp_enabled = True
user.totp_secret = "JBSWY3DPEHPK3PXP" # dummy secret
# Store a backup code
plaintext_code = "ABCD1234"
from services.auth import hash_password
backup_code_row = BackupCode(
id=uuid.uuid4(),
user_id=user.id,
code_hash=hash_password(plaintext_code),
used_at=None,
)
db_session.add(backup_code_row)
await db_session.commit()
resp = await _login(authed_client, email="bcu@example.com", backup_code=plaintext_code)
assert resp.status_code == 200, resp.text
data = resp.json()
assert "access_token" in data
# Verify the code was marked used
await db_session.refresh(backup_code_row)
assert backup_code_row.used_at is not None
@pytest.mark.asyncio
async def test_login_backup_code_reuse(authed_client, db_session: AsyncSession):
"""Using the same backup code a second time returns 401."""
await _register(authed_client, handle="bcr", email="bcr@example.com")
result = await db_session.execute(select(User).where(User.email == "bcr@example.com"))
user = result.scalar_one()
user.totp_enabled = True
user.totp_secret = "JBSWY3DPEHPK3PXP"
plaintext_code = "EFGH5678"
from services.auth import hash_password
backup_code_row = BackupCode(
id=uuid.uuid4(),
user_id=user.id,
code_hash=hash_password(plaintext_code),
used_at=None,
)
db_session.add(backup_code_row)
await db_session.commit()
# First use: should succeed
resp1 = await _login(authed_client, email="bcr@example.com", backup_code=plaintext_code)
assert resp1.status_code == 200, resp1.text
# Second use: should fail
resp2 = await _login(authed_client, email="bcr@example.com", backup_code=plaintext_code)
assert resp2.status_code == 401
assert "Invalid or already used code" in resp2.json()["detail"]
@pytest.mark.asyncio
async def test_login_backup_code_invalid(authed_client, db_session: AsyncSession):
"""POST /api/auth/login with an unknown backup code returns 401."""
await _register(authed_client, handle="bci", email="bci@example.com")
result = await db_session.execute(select(User).where(User.email == "bci@example.com"))
user = result.scalar_one()
user.totp_enabled = True
user.totp_secret = "JBSWY3DPEHPK3PXP"
await db_session.commit()
resp = await _login(authed_client, email="bci@example.com", backup_code="XXXXXXXX")
assert resp.status_code == 401
assert "Invalid or already used code" in resp.json()["detail"]
@pytest.mark.asyncio
async def test_login_totp_takes_precedence(authed_client, db_session: AsyncSession):
"""When both totp_code and backup_code are provided, the TOTP path is used (not backup)."""
await _register(authed_client, handle="ttp", email="ttp@example.com")
result = await db_session.execute(select(User).where(User.email == "ttp@example.com"))
user = result.scalar_one()
user.totp_enabled = True
user.totp_secret = "JBSWY3DPEHPK3PXP"
# Store a valid backup code
plaintext_code = "TOTP1234"
from services.auth import hash_password
backup_code_row = BackupCode(
id=uuid.uuid4(),
user_id=user.id,
code_hash=hash_password(plaintext_code),
used_at=None,
)
db_session.add(backup_code_row)
await db_session.commit()
# Use both fields — totp_code is wrong, backup_code is valid
# If TOTP takes precedence, the wrong totp_code should cause 401
with patch("services.auth.verify_totp", return_value=False) as mock_totp:
resp = await _login(
authed_client,
email="ttp@example.com",
totp_code="000000",
backup_code=plaintext_code,
)
# verify_totp was called (TOTP path taken)
mock_totp.assert_called_once()
# The backup code path was NOT taken — code remains unused
await db_session.refresh(backup_code_row)
assert backup_code_row.used_at is None, "Backup code should NOT have been consumed when totp_code is provided"
assert resp.status_code == 401
# ---------------------------------------------------------------------------
# Phase 4 DOC-01 preferences endpoint tests
# ---------------------------------------------------------------------------
async def test_get_preferences_default(async_client, auth_user):
"""GET /api/auth/me/preferences returns default pdf_open_mode='in_app'."""
resp = await async_client.get("/api/auth/me/preferences", headers=auth_user["headers"])
assert resp.status_code == 200
data = resp.json()
assert "pdf_open_mode" in data
assert data["pdf_open_mode"] in ("in_app", "new_tab") # column default varies by env
async def test_patch_preferences_in_app(async_client, auth_user):
"""PATCH /api/auth/me/preferences with pdf_open_mode='in_app' stores and returns value."""
resp = await async_client.patch(
"/api/auth/me/preferences",
json={"pdf_open_mode": "in_app"},
headers=auth_user["headers"],
)
assert resp.status_code == 200
assert resp.json()["pdf_open_mode"] == "in_app"
async def test_patch_preferences_new_tab(async_client, auth_user):
"""PATCH /api/auth/me/preferences with pdf_open_mode='new_tab' stores and returns value."""
resp = await async_client.patch(
"/api/auth/me/preferences",
json={"pdf_open_mode": "new_tab"},
headers=auth_user["headers"],
)
assert resp.status_code == 200
assert resp.json()["pdf_open_mode"] == "new_tab"
async def test_patch_preferences_invalid_value(async_client, auth_user):
"""PATCH /api/auth/me/preferences with invalid pdf_open_mode returns 422."""
resp = await async_client.patch(
"/api/auth/me/preferences",
json={"pdf_open_mode": "browser"},
headers=auth_user["headers"],
)
assert resp.status_code == 422
async def test_patch_preferences_persists(async_client, auth_user):
"""PATCH then GET confirms persistence of pdf_open_mode."""
await async_client.patch(
"/api/auth/me/preferences",
json={"pdf_open_mode": "new_tab"},
headers=auth_user["headers"],
)
resp = await async_client.get("/api/auth/me/preferences", headers=auth_user["headers"])
assert resp.status_code == 200
assert resp.json()["pdf_open_mode"] == "new_tab"
async def test_preferences_requires_auth(async_client):
"""GET /api/auth/me/preferences returns 401 without a bearer token."""
resp = await async_client.get("/api/auth/me/preferences")
assert resp.status_code == 401
async def test_patch_preferences_requires_auth(async_client):
"""PATCH /api/auth/me/preferences returns 401 without a bearer token."""
resp = await async_client.patch(
"/api/auth/me/preferences",
json={"pdf_open_mode": "in_app"},
)
assert resp.status_code == 401
# ── Tests — sessions_revoked (CR-01, CR-02, CR-03) ───────────────────────────
@pytest.mark.asyncio
async def test_change_password_revokes_other_sessions(authed_client, db_session: AsyncSession):
"""change_password revokes other sessions and returns sessions_revoked >= 1."""
from services import auth as auth_service
await _register(authed_client, handle="cpr1", email="cpr1@example.com")
login_resp = await _login(authed_client, email="cpr1@example.com")
token = login_resp.json()["access_token"]
result = await db_session.execute(select(User).where(User.email == "cpr1@example.com"))
user = result.scalar_one()
# Insert a second session token (the "other device") directly in the DB
await auth_service.create_refresh_token(db_session, user.id)
with patch("services.auth.check_hibp", return_value=False):
resp = await authed_client.post(
"/api/auth/change-password",
json={"current_password": "ValidPass12!", "new_password": "NewStrong99!@"},
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
assert resp.json()["sessions_revoked"] >= 1
result2 = await db_session.execute(
select(RefreshToken).where(RefreshToken.user_id == user.id)
)
rows = result2.scalars().all()
assert any(r.revoked for r in rows), "Expected at least one revoked RefreshToken row"
@pytest.mark.asyncio
async def test_enable_totp_revokes_other_sessions(authed_client, db_session: AsyncSession):
"""enable_totp revokes other sessions and returns sessions_revoked >= 1."""
from services import auth as auth_service
await _register(authed_client, handle="etr1", email="etr1@example.com")
login_resp = await _login(authed_client, email="etr1@example.com")
token = login_resp.json()["access_token"]
result = await db_session.execute(select(User).where(User.email == "etr1@example.com"))
user = result.scalar_one()
user.totp_secret = "JBSWY3DPEHPK3PXP"
await db_session.commit()
# Insert a second session token (the "other device")
await auth_service.create_refresh_token(db_session, user.id)
with patch("services.auth.verify_totp", return_value=True):
with patch("services.auth.store_backup_codes", return_value=None):
resp = await authed_client.post(
"/api/auth/totp/enable",
json={"code": "123456"},
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
data = resp.json()
assert data["sessions_revoked"] >= 1
result2 = await db_session.execute(
select(RefreshToken).where(RefreshToken.user_id == user.id)
)
rows = result2.scalars().all()
assert any(r.revoked for r in rows), "Expected at least one revoked RefreshToken row"
@pytest.mark.asyncio
async def test_disable_totp_revokes_other_sessions(authed_client, db_session: AsyncSession):
"""disable_totp revokes other sessions and returns sessions_revoked >= 1."""
from services import auth as auth_service
await _register(authed_client, handle="dtr1", email="dtr1@example.com")
login_resp = await _login(authed_client, email="dtr1@example.com")
token = login_resp.json()["access_token"]
result = await db_session.execute(select(User).where(User.email == "dtr1@example.com"))
user = result.scalar_one()
user.totp_enabled = True
user.totp_secret = "JBSWY3DPEHPK3PXP"
await db_session.commit()
# Insert a second session token (the "other device")
await auth_service.create_refresh_token(db_session, user.id)
resp = await authed_client.delete(
"/api/auth/totp",
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
data = resp.json()
assert data["sessions_revoked"] >= 1
result2 = await db_session.execute(
select(RefreshToken).where(RefreshToken.user_id == user.id)
)
rows = result2.scalars().all()
assert any(r.revoked for r in rows), "Expected at least one revoked RefreshToken row"
# ── Phase 7.2 Wave 0 stubs: NBF-write on security events ─────────────────────
# These three tests are xfailed with strict=False. When Wave 2 (Plan 03) adds
# user_nbf writes to the handlers, these stubs are promoted to real assertions
# by replacing `pytest.xfail(...)` with actual Redis key assertions.
#
# Implementation hint for Wave 2 (kept here for context):
# After the API call returns success, fetch the user_id from the login response
# payload, then:
# nbf_bytes = await authed_client._transport.app.state.redis.get(f"user_nbf:{user_id}")
# assert nbf_bytes is not None
# assert int(nbf_bytes.decode() if isinstance(nbf_bytes, (bytes, bytearray)) else nbf_bytes) > 0
@pytest.mark.asyncio
async def test_change_password_writes_user_nbf_to_redis(authed_client, db_session: AsyncSession):
"""POST /api/auth/change-password must write user_nbf:{user_id} to Redis."""
from main import app
from sqlalchemy import select as sa_select
await _register(authed_client, handle="nbfw_cp1", email="nbfw_cp1@example.com")
login_resp = await _login(authed_client, email="nbfw_cp1@example.com")
token = login_resp.json()["access_token"]
result = await db_session.execute(sa_select(User).where(User.email == "nbfw_cp1@example.com"))
user = result.scalar_one()
with patch("services.auth.check_hibp", return_value=False):
resp = await authed_client.post(
"/api/auth/change-password",
json={"current_password": "ValidPass12!", "new_password": "NewStrong99!@"},
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
nbf_bytes = await app.state.redis.get(f"user_nbf:{user.id}")
assert nbf_bytes is not None
assert int(nbf_bytes.decode() if isinstance(nbf_bytes, (bytes, bytearray)) else nbf_bytes) > 0
@pytest.mark.asyncio
async def test_enable_totp_writes_user_nbf_to_redis(authed_client, db_session: AsyncSession):
"""POST /api/auth/totp/enable must write user_nbf:{user_id} to Redis."""
from main import app
from sqlalchemy import select as sa_select
await _register(authed_client, handle="nbfw_et1", email="nbfw_et1@example.com")
login_resp = await _login(authed_client, email="nbfw_et1@example.com")
token = login_resp.json()["access_token"]
result = await db_session.execute(sa_select(User).where(User.email == "nbfw_et1@example.com"))
user = result.scalar_one()
user.totp_secret = "JBSWY3DPEHPK3PXP"
await db_session.commit()
with patch("services.auth.verify_totp", return_value=True):
with patch("services.auth.store_backup_codes", return_value=None):
resp = await authed_client.post(
"/api/auth/totp/enable",
json={"code": "123456"},
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
nbf_bytes = await app.state.redis.get(f"user_nbf:{user.id}")
assert nbf_bytes is not None
assert int(nbf_bytes.decode() if isinstance(nbf_bytes, (bytes, bytearray)) else nbf_bytes) > 0
@pytest.mark.asyncio
async def test_disable_totp_writes_user_nbf_to_redis(authed_client, db_session: AsyncSession):
"""DELETE /api/auth/totp must write user_nbf:{user_id} to Redis."""
from main import app
from sqlalchemy import select as sa_select
await _register(authed_client, handle="nbfw_dt1", email="nbfw_dt1@example.com")
login_resp = await _login(authed_client, email="nbfw_dt1@example.com")
token = login_resp.json()["access_token"]
result = await db_session.execute(sa_select(User).where(User.email == "nbfw_dt1@example.com"))
user = result.scalar_one()
user.totp_enabled = True
user.totp_secret = "JBSWY3DPEHPK3PXP"
await db_session.commit()
resp = await authed_client.delete(
"/api/auth/totp",
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
nbf_bytes = await app.state.redis.get(f"user_nbf:{user.id}")
assert nbf_bytes is not None
assert int(nbf_bytes.decode() if isinstance(nbf_bytes, (bytes, bytearray)) else nbf_bytes) > 0
@pytest.mark.asyncio
async def test_password_reset_confirm_writes_user_nbf_to_redis(authed_client, db_session: AsyncSession):
"""POST /api/auth/password-reset/confirm must write user_nbf:{user_id} to Redis (CR-02 gap closure)."""
from main import app
from services.auth import create_password_reset_token
from sqlalchemy import select as sa_select
await _register(authed_client, handle="nbfw_prc1", email="nbfw_prc1@example.com")
result = await db_session.execute(sa_select(User).where(User.email == "nbfw_prc1@example.com"))
user = result.scalar_one()
reset_token = create_password_reset_token(str(user.id))
resp = await authed_client.post(
"/api/auth/password-reset/confirm",
json={"token": reset_token, "new_password": "NewSecure!Pass99#"},
)
assert resp.status_code == 200
nbf_bytes = await app.state.redis.get(f"user_nbf:{user.id}")
assert nbf_bytes is not None
assert int(nbf_bytes.decode() if isinstance(nbf_bytes, (bytes, bytearray)) else nbf_bytes) > 0