aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests/conftest.py
Commit message (Collapse)AuthorAgeFilesLines
* style: ruff's own fixes, mechanically appliedChristophe Besson6 days1-4/+10
| | | | | | | | | | | | | | | | | | | | `ruff check .` had gone unrun long enough to report 568 errors, which is the same as having no linter: the next real finding would have been invisible in the noise. This is the 521 it fixes by itself, in 173 files, and nothing else — the 98 it cannot fix are the next commit. What actually changed: import sorting (225), imports nobody used (87, none of them a re-export — no `__init__.py` is touched, which was the one way this could have broken an import elsewhere), `datetime.timezone.utc` to `datetime.UTC` (69) and `asyncio.TimeoutError` to `TimeoutError` (18), both plain aliases on the 3.12 this project requires, `Optional[X]` to `X | None` (24), and f-strings with nothing to interpolate (19). Checked rather than assumed: every module in the three packages still imports, and the suite is 2893 passed — the same count, test for test, as the merge before it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(hub): session lifetime is an admin setting, and a browser signs out ↵Christophe Besson11 days1-1/+1
| | | | | | | | | | | when idle Browser idle sign-out (media playback counts as activity; not the desktop app), refresh idle window and maximum session length, in hours. Sign-out now revokes on the hub, and the profile has "sign out everywhere". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XuNrwLf5EFWCMHzfoEvnpm
* fix(hub): the mail server is not a relayChristophe Besson13 days1-1/+6
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | The previous commit metered the paths that send mail. It was not enough, and saying it was would have been wrong: a 60-second cooldown per account still allows one stranger a minute — 1440 a day — and registration is open, so "per account" is a bound an attacker buys more of. And there was a third door nobody had counted. POST /v1/users/register an address nobody has verified PATCH /v1/users/me an address nobody has verified, signed in POST /v1/users/password/reset only the address already on file POST /v1/groups/{id}/invite-notify only a registered member's address The widest was the register *resend* branch: no token, no captcha, and the username and address are the caller's own from a moment ago — registering a victim's address once bought the right to mail them at the endpoint's rate limit for as long as the account stayed pending. So the bound moves into `mail.py`, where every message passes one function. `purpose` is keyword-required and checked against a closed list, so a helper that names anything else does not send and one that names nothing is a TypeError rather than an unrestricted send. Under it: - a bound per **recipient**, across every purpose, account and endpoint — what a person being mail-bombed actually experiences, and the only bound that describes it. Keyed on a hash, because this would otherwise be the one place in the hub holding plaintext addresses in memory (S2) - an instance-wide hourly ceiling, which cannot be bought with more accounts - a cooldown on the resend branch, a cooldown and a daily ceiling on the address change, and the IP-log entry that endpoint never wrote — alone among the ones that mail The ceiling on address changes counts IP-log rows, not EmailVerification: the handler deletes this account's unverified rows before writing a new one, so counting those counts one, always. Which is what the first version of it did. Refusals never carry the address: that line goes to the journal. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T4YmK41VsEURWFdop4EEeT
* fix(hub): stop the maintenance loop racing the tests, and pin _ASSETSChristophe Besson2026-09-021-0/+27
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Two defects found while closing out the Search merge, neither of them in that feature. The maintenance loop. create_app's lifespan starts cleanup_loop as an asyncio task, so every test — each entering that lifespan — ran a purge pass concurrently with its own requests. On SQLite :memory: that is not merely noisy: the engine uses a StaticPool, one connection for the whole process, so the request's session and the cleanup task's session interleave transactions on the same connection. A registration could commit and then be invisible to the login three lines later, surfacing as 401 Invalid credentials for an account created moments before, in roughly one run of test_node_ws_auth.py in four. The purge itself is not at fault and this is not a production condition. A passive SQL listener caught the DELETE removing 0 rows, and the INSERT carrying status='active' — so neither the pending-account mechanism nor the purge filter is involved, and PostgreSQL gives every session its own connection. What the fixture removes is the second user of the shared one. 60 runs of the previously flaky file, 0 failures; reproductions before the fix landed on attempts 4, 6, 13 and 29 of separate loops, so a clean run of 60 has about a 1% chance of being luck. _ASSETS. source-merge.js shipped missing from webapp._ASSETS, the cache-busting hash's input list — exactly the silent failure docs/apps.md §4 step 5 warns about: the file changes, the asset URL does not, and a browser holding the old page keeps the old copy. Harmless this time only because search-page.js changed in the same commit and is listed, which is the worst way for it to go unnoticed. Found by re-reading that checklist for the doc pass, not by any test — so there is a test now, holding _ASSETS to every .js in static/ (sw.js excepted, unversioned on purpose). It was the only one missing. Phase 9 of docs/refactoring-search.md also lands here: mediacenter.md §10.6, musicbay.md §9b, photos.md §10b, apps.md §2b and step 5. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AbwJDbNTkiRUh7HTWEoyss
* feat: passphrase change and account recovery (auth-confirm)Christophe Besson2026-09-011-1/+1
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | The passphrase derives two independent client-side values: auth_key (the hub verifier) and bundle_key (AES-GCM key for the per-node identity bundles, which live on nodes and never on the hub). Changing or recovering a passphrase is therefore two operations — swap the hub verifier, and re-wrap every reachable node's identity bundle. Flow A — change a known passphrase (Profile page) - POST /v1/users/password re-proves the current passphrase, swaps pw_hash/salt/version, revokes every refresh token and returns a fresh pair so the tab that made the change stays signed in. - MeshBayTransport.rewrapAllNodes: for every group's online node, connect with the old key, read the identity off the handshake, store it back under the new key. Returns updated / unreachable / failed so the UI can point at the operator-unpin fallback for the gaps. Always-shown confirmation dialog listing reachable and unreachable groups. Recovery key - keyderive.js generateRecoveryKey (32 random bytes, grouped Base32) and deriveRecoveryKey (HKDF-SHA256, domain meshbay:recovery:v1:<username>). - Every per-node identity gets a second copy wrapped under the recovery key: keypair_bundles.bundle_enc_recovery (node-only column, added in _SCHEMA_KEYPAIR and via a PRAGMA-guarded ALTER for existing DBs), carried on keypair_bundle_store / _resp. MNP 0.13 -> 0.14, additive. - session.recoveryKey is persisted in IndexedDB (slot rk) and lazy-loaded on connect, so a group joined in any later session still leaves a recovery copy. - Shown once at registration; optionally folded into the verification e-mail as a pass-through the hub never stores or logs, with an opt-out. - Profile -> Recovery key re-loads R and backfills every reachable node via rewrapAllNodes in bundleKey mode (no passphrase re-entry). Flow B — recover a lost passphrase (#/reset, linked from sign-in) - POST /v1/users/password/reset-request {username, email}: both must be the pair on file, checked against the blind email_hash (never decrypted). A mismatch — wrong e-mail, unknown username, non-active account — takes the identical no-op path (no code, no mail, same 200), so it reveals nothing and cannot be used to spray reset mail from a username alone. 5/min, 1-hour single-use code. - POST /v1/users/password/reset {username, code, new_auth_key}: same expiry / attempts / single-use checks as e-mail verification; revokes every session and deletes every registered device key so a stored one cannot sign back in past the reset. - ResetPasswordPage: request code -> code + optional recovery key + new passphrase -> reset + sign-in -> fan-out. connect() falls back to the recovery-wrapped copy when the passphrase key cannot open bundle_enc. Without a recovery key: sign-in is restored and each group needs the operator-unpin fallback. Supporting fixes (found in live testing) - member unpin now also deletes the keypair bundle; connect() mints a fresh identity when handed a bundle it cannot open (unless _rewrapOnly, set by rewrapAllNodes), so a rejoin completes instead of dead-ending before the invite-code prompt. - A browser with no bundle key gets a passphrase prompt on the group page instead of a "go back to the browser you registered on" message. - RegisterPage / LoginPage / ResetPasswordPage trim the username so every key derivation matches the hub's stored form. Docs: docs/auth-confirm.md. Locale keys across all ten catalogues. Tests: test_password_change, test_password_reset, test_recovery_email, test_recovery_key, test_rewrap_fanout, test_bundle_store_recovery, plus additions to test_admin_ops_mnp and test_webrtc_transport. Hub suite 492 passed; node suite 741 passed (the lone test_packaging_units failure is a pre-existing RPM-spec flake, reproducible on main). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GGkxJW9br8Y9bhT8ywJ3oc
* feat: email verification for registration, email change, and invitationsChristophe Besson2026-08-311-0/+12
| | | | | | | | | | | | | | | | | | Registration now creates a pending account and sends a 6-digit code via email; the account activates only after verification. Email changes on the profile page follow the same flow. Group invitations send a notification email to the invitee (without revealing their address to the inviter) containing the invite code and hub link. Backend: blind HMAC-SHA256 email index for uniqueness without decryption, mail.py for localhost Postfix delivery, verification endpoints, cleanup of expired codes and stale pending accounts, startup backfill of email_hash for existing users. Frontend: 3-phase register page, inline email change verification on profile, invite-notify call with status display. All 10 locales updated. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat(account): a user can delete their own account, an admin can delete oneChristophe Besson2026-08-141-0/+14
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Both go through the same erasure, so there is one description of what happens rather than two that drift. Gone: credentials, email, node key, group memberships, notifications, refresh tokens, node registrations. The username is released. Kept, on purpose and stated in the UI: the row itself, emptied, and the IP log that points at it. Those logs exist for a year to answer legal requests, and a log that can no longer say whose connection it recorded keeps the data while losing the only thing it is for. So the account becomes a tombstone rather than a hole in the table. Out of reach, also stated: files uploaded to nodes, and the identity keys nodes pinned. Those are on machines the hub does not command, and only their operators can remove them — `member unpin` and a delete on their own disk. Saying so in the confirmation matters more than the button. Owning groups blocks deletion, with the list. Cascading would delete other people's groups out from under them; the account holder can hand them over or delete them first, deliberately. Self-deletion re-checks the passphrase. A live token may be a borrowed laptop or a tab left open, and it is not consent to something irreversible. Admin deletion requires admin rather than moderator: suspension is the reversible moderation tool and stays one click away. A deleted account's access token stops working at once — the status check already refuses anything but "active", which the tests now pin down, because refresh tokens being gone would otherwise leave up to an hour of usable session. Tests: 8 covering what survives and what does not, plus a db_session fixture for assertions that cannot honestly be made through the API. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(hub): Phase 8 — Hub v2 security hardening + production readinessChristophe Besson2026-08-101-0/+8
| | | | | | | | | | | | | | | | | | | | 8.1 Config-based admin authz (require_admin on all admin endpoints) 8.2 Email encrypted at rest (AES-256-GCM, HKDF from hub Ed25519 key) 8.3 Refresh token rotation with family-based reuse detection 8.4 Federation persistence (HubPeer model replaces in-memory dict) 8.5 Federation token verification now async (DB-backed) 8.6 CSAM hash check wired into swarm registration flow 8.7 Rate limiting on auth endpoints (5/10/20 per minute) 8.8 Healthcheck endpoint (GET /v1/health, no auth) 8.9 IP log cleanup background task (365-day retention) 8.10 Argon2id params bumped to 256 MB (pw_version, rehash on login) Deployed to meshbay.org — schema migrated, existing emails encrypted. 117 tests pass (29 hub, 88 common+node). Resolves security review items S1, S2, S5. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat(hub): add production hub — config, auth, API routers, testsChristophe Besson2026-08-091-0/+64
config.py: TOML + env var priority. auth.py: Argon2id passwords, JWT EdDSA with jti, refresh token hashed (blake3). Routers: hub (info/pubkey), users (register/login/refresh/pubkeys), nodes (announce/get), groups (create/gek-bundle/gek-retrieve). Rate limiting via slowapi. app.py factory with lifespan. All 40 tests pass (SQLite in-memory, no PostgreSQL required). Fix: remove tests/__init__.py to resolve namespace conflicts. Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>