Settings → Activity: an audit log of what happened to accounts
CI & Build / Python lint (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
Android / Build, or is the channel already serving this? (push) Successful in 3s
Android / Core and FFI clippy and tests (push) Skipped
Android / Kotlin + Rust (APK) (push) Skipped
Android / Build the server image (push) Skipped
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Web typecheck and unit tests (push) Successful in 10s
CI & Build / Python tests (push) Successful in 14s
CI & Build / integration (push) Successful in 1m27s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 1m47s
CI & Build / Build & push image (push) Successful in 45s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m24s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m4s
Desktop (Tauri) / Update manifest (push) Successful in 4s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
Android / Build, or is the channel already serving this? (push) Successful in 3s
Android / Core and FFI clippy and tests (push) Skipped
Android / Kotlin + Rust (APK) (push) Skipped
Android / Build the server image (push) Skipped
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Web typecheck and unit tests (push) Successful in 10s
CI & Build / Python tests (push) Successful in 14s
CI & Build / integration (push) Successful in 1m27s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 1m47s
CI & Build / Build & push image (push) Successful in 45s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m24s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m4s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Sign-ins and failed sign-ins, accounts created and sign-ups refused, password changes, resets and reset links, devices linked and unlinked, invites made and revoked. Each is kept in `audit_events` with the address it came from, for `audit_retention_days` (Settings → Security, 90 by default, 0 keeps them forever), and listed newest first for admins under Settings → Activity. The retention loop deletes older events. `audit.record` writes in its own session, so a refusal is kept even when the request's transaction rolls back. A failure to record is logged and swallowed, never the reason a sign-in fails. A throttled attempt (429) is not recorded: a row per refused request would make each request in a flood cost a database write. Throttle trips stay in the app log. Also: the storage-limit test puts `storage_quota_gb` back afterwards, since settings outlive the per-test truncate. #2939 §5 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -26,10 +26,11 @@ import pytest
|
||||
import pytest_asyncio
|
||||
from sqlalchemy import delete, func, select, text, update
|
||||
|
||||
from inkwell import mailer, ratelimit
|
||||
from inkwell import audit, mailer, ratelimit
|
||||
from inkwell.app import create_app
|
||||
from inkwell.config import Config
|
||||
from inkwell.db import dispose_engine, session_scope
|
||||
from inkwell.models.audit_event import AuditEvent
|
||||
from inkwell.models.invite import Invite
|
||||
from inkwell.models.password_reset import PasswordReset
|
||||
from inkwell.models.settings import Setting
|
||||
@@ -51,7 +52,7 @@ pytestmark = pytest.mark.integration
|
||||
|
||||
# Every table the tests touch, child-first so FKs never block the truncate.
|
||||
# RESTART IDENTITY + CASCADE keeps this honest if a table gains children later.
|
||||
_TABLES = "notes, note_revisions, note_labels, note_link_previews, labels, shares, share_revocations, note_user_state, group_members, groups, invites, password_resets, users"
|
||||
_TABLES = "notes, note_revisions, note_labels, note_link_previews, labels, shares, share_revocations, note_user_state, group_members, groups, invites, password_resets, audit_events, users"
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
@@ -481,6 +482,7 @@ async def test_the_security_group_reaches_the_admin_ui(app_client, db):
|
||||
"register_window_minutes",
|
||||
"reset_emails_per_account",
|
||||
"client_downloads_per_hour",
|
||||
"audit_retention_days",
|
||||
}
|
||||
# The UI renders a number input from these, and it cannot offer a safe range it
|
||||
# was never told about.
|
||||
@@ -1274,10 +1276,24 @@ async def test_an_admin_and_a_zero_setting_have_no_storage_limit(app_client, db)
|
||||
admin_use = await (await app_client.get("/api/auth/storage")).get_json()
|
||||
assert admin_use["limit_bytes"] is None
|
||||
|
||||
await _set_for_the_test("storage_quota_gb", 0)
|
||||
try:
|
||||
assert (await (await guest.get("/api/auth/storage")).get_json())["limit_bytes"] is None
|
||||
finally:
|
||||
await _back_to_default("storage_quota_gb")
|
||||
|
||||
|
||||
async def _set_for_the_test(key: str, value) -> None:
|
||||
"""Settings outlive the per-test truncate: pair this with `_back_to_default`."""
|
||||
async with session_scope() as fresh:
|
||||
await set_settings(fresh, {"storage_quota_gb": 0})
|
||||
await set_settings(fresh, {key: value})
|
||||
await fresh.commit()
|
||||
|
||||
|
||||
async def _back_to_default(key: str) -> None:
|
||||
async with session_scope() as fresh:
|
||||
await fresh.execute(delete(Setting).where(Setting.key == key))
|
||||
await fresh.commit()
|
||||
assert (await (await guest.get("/api/auth/storage")).get_json())["limit_bytes"] is None
|
||||
|
||||
|
||||
# --- Changing a password, and signing out everywhere else (#5105, practice 4) ---
|
||||
@@ -1375,6 +1391,74 @@ async def test_revoking_this_device_from_a_web_session_is_a_bad_request(app_clie
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
# --- The audit log (#2939 §5) --------------------------------------------------
|
||||
|
||||
|
||||
async def _activity(client) -> list[dict]:
|
||||
resp = await client.get("/api/accounts/activity")
|
||||
assert resp.status_code == 200, await resp.get_data(as_text=True)
|
||||
return (await resp.get_json())["events"]
|
||||
|
||||
|
||||
async def test_the_activity_list_keeps_what_happened_to_accounts(app_client, db):
|
||||
other, bearer = await _elsewhere(app_client)
|
||||
wrong = await create_app().test_client().post(
|
||||
"/api/auth/login", json={"email": "owner@example.test", "password": "not-the-password"}
|
||||
)
|
||||
assert wrong.status_code == 401
|
||||
await app_client.post(
|
||||
"/api/auth/password", json={"current_password": _PASSWORD, "new_password": "a-brand-new-password"}
|
||||
)
|
||||
|
||||
events = await _activity(app_client)
|
||||
# Newest first.
|
||||
assert [e["event"] for e in events] == [
|
||||
audit.PASSWORD_CHANGED,
|
||||
audit.SIGN_IN_FAILED,
|
||||
audit.DEVICE_LINKED,
|
||||
audit.SIGN_IN,
|
||||
audit.REGISTERED,
|
||||
]
|
||||
by_name = {e["event"]: e for e in events}
|
||||
assert by_name[audit.SIGN_IN_FAILED]["detail"] == "sign-in: bad password"
|
||||
assert by_name[audit.DEVICE_LINKED]["detail"] == "Phone"
|
||||
assert by_name[audit.PASSWORD_CHANGED]["detail"] == "1 device unlinked"
|
||||
assert by_name[audit.REGISTERED]["detail"] == "first account, admin"
|
||||
assert all(e["email"] == "owner@example.test" and e["address"] for e in events)
|
||||
|
||||
|
||||
async def test_a_refused_registration_is_kept_though_its_transaction_rolled_back(app_client, db):
|
||||
await _admin_with_invite(app_client)
|
||||
refused = await _register("stranger@example.test", invite="not-a-real-invite")
|
||||
assert refused.status_code == 403
|
||||
|
||||
refusals = [e for e in await _activity(app_client) if e["event"] == audit.REGISTRATION_REFUSED]
|
||||
assert [(e["email"], e["detail"]) for e in refusals] == [("stranger@example.test", "invite didn't hold")]
|
||||
|
||||
|
||||
async def test_only_an_admin_reads_the_activity_list(app_client, db):
|
||||
guest, _ = await _admin_and_guest(app_client)
|
||||
assert (await guest.get("/api/accounts/activity")).status_code == 403
|
||||
|
||||
|
||||
async def test_activity_older_than_its_retention_is_swept(app_client, db):
|
||||
await app_client.post("/api/auth/register", json={"email": "owner@example.test", "password": _PASSWORD})
|
||||
now = datetime.now(timezone.utc)
|
||||
async with session_scope() as fresh:
|
||||
fresh.add(AuditEvent(event=audit.SIGN_IN, email="old@example.test", at=now - timedelta(days=91)))
|
||||
await fresh.commit()
|
||||
|
||||
await _set_for_the_test("audit_retention_days", 0)
|
||||
try:
|
||||
assert await audit.sweep_once() == 0, "0 keeps every event"
|
||||
finally:
|
||||
await _back_to_default("audit_retention_days")
|
||||
|
||||
assert await audit.sweep_once() == 1
|
||||
emails = {e["email"] for e in await _activity(app_client)}
|
||||
assert emails == {"owner@example.test"}
|
||||
|
||||
|
||||
# --- Sharing a note (#5174) ---------------------------------------------------
|
||||
#
|
||||
# Owner, a recipient, and a stranger who is on the instance but not shared with.
|
||||
|
||||
Reference in New Issue
Block a user