snapshot of https://github.com/ActivityWatch/aw-server.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Build / Test on ubuntu-latest (push) Successful in 3m6s
Lint / lint (push) Successful in 22s
Build / typecheck (ubuntu-latest, 3.9) (push) Successful in 1m49s
Lint / format (push) Successful in 42s
Build / Test on macOS-latest (push) Has been cancelled
Build / Test on windows-latest (push) Has been cancelled
CodeQL / Analyze (python) (push) Failing after 1m31s
Some checks failed
Build / Test on ubuntu-latest (push) Successful in 3m6s
Lint / lint (push) Successful in 22s
Build / typecheck (ubuntu-latest, 3.9) (push) Successful in 1m49s
Lint / format (push) Successful in 42s
Build / Test on macOS-latest (push) Has been cancelled
Build / Test on windows-latest (push) Has been cancelled
CodeQL / Analyze (python) (push) Failing after 1m31s
This commit is contained in:
48
tests/conftest.py
Executable file
48
tests/conftest.py
Executable file
@@ -0,0 +1,48 @@
|
||||
import logging
|
||||
import os
|
||||
|
||||
import pytest
|
||||
from aw_client import ActivityWatchClient
|
||||
from aw_server.server import AWFlask
|
||||
|
||||
logging.basicConfig(level=logging.WARN)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_aw_profile_after_test():
|
||||
"""export_profile() writes os.environ directly; monkeypatch.delenv does
|
||||
not record an undo when the var was already unset, so later tests would
|
||||
inherit a leftover profile."""
|
||||
yield
|
||||
os.environ.pop("AW_PROFILE", None)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def app():
|
||||
# AWFlask does not go through parse_settings(), so a leftover AW_PROFILE
|
||||
# from the environment (or a prior test) would disagree with testing=True.
|
||||
old = os.environ.pop("AW_PROFILE", None)
|
||||
application = AWFlask("127.0.0.1", testing=True)
|
||||
if old is not None:
|
||||
os.environ["AW_PROFILE"] = old
|
||||
return application
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def flask_client(app):
|
||||
yield app.test_client()
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def aw_client():
|
||||
# TODO: Could it be possible to write a sisterclass of ActivityWatchClient
|
||||
# which calls aw_server.api directly? Would it be of use? Would add another
|
||||
# layer of integration tests that are actually more like unit tests.
|
||||
c = ActivityWatchClient("aw-client-test", testing=True)
|
||||
yield c
|
||||
|
||||
# Delete test buckets after all tests needing the fixture have been run
|
||||
buckets = c.get_buckets()
|
||||
for bucket_id in buckets:
|
||||
if bucket_id.startswith("test-"):
|
||||
c.delete_bucket(bucket_id)
|
||||
297
tests/test_client.py
Executable file
297
tests/test_client.py
Executable file
@@ -0,0 +1,297 @@
|
||||
import json
|
||||
import logging
|
||||
import random
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pprint import pprint
|
||||
from time import sleep
|
||||
|
||||
import pytest
|
||||
from aw_core.models import Event
|
||||
|
||||
logging.basicConfig(level=logging.WARN)
|
||||
|
||||
# TODO: Could it be possible to write a sisterclass of ActivityWatchClient
|
||||
# which calls aw_server.api directly? Would it be of use? Would add another
|
||||
# layer of integration tests that are actually more like unit tests.
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def bucket(aw_client):
|
||||
bucket_id = "test-" + str(random.randint(0, 10**5))
|
||||
event_type = "testevents"
|
||||
aw_client.create_bucket(bucket_id, event_type, queued=False)
|
||||
print(f"Created bucket {bucket_id}")
|
||||
sleep(1)
|
||||
yield bucket_id
|
||||
aw_client.delete_bucket(bucket_id)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def queued_bucket(aw_client, bucket):
|
||||
# FIXME: We need a way to clear the failed_requests file in order
|
||||
# to have tests behave reasonably between runs.
|
||||
aw_client.connect()
|
||||
yield bucket
|
||||
aw_client.disconnect()
|
||||
|
||||
|
||||
def test_get_info(aw_client):
|
||||
info = aw_client.get_info()
|
||||
assert info["testing"]
|
||||
|
||||
|
||||
def test_export(aw_client):
|
||||
export = aw_client._get("export").json()
|
||||
for bucket_id, bucket in export["buckets"].items():
|
||||
assert bucket["id"]
|
||||
assert "events" in bucket
|
||||
# print(export)
|
||||
|
||||
|
||||
def _create_heartbeat_events(
|
||||
start=datetime.now(tz=timezone.utc), delta=timedelta(seconds=1)
|
||||
):
|
||||
e1_ts = start
|
||||
e2_ts = e1_ts + delta
|
||||
|
||||
# Needed since server (or underlying datastore) drops precision up to milliseconds.
|
||||
# Update: Even with millisecond precision it sometimes fails. (tried using `round` and `int`)
|
||||
# Now rounding down to 10ms precision to prevent random failure.
|
||||
# 10ms precision at least seems to work well.
|
||||
# TODO: Figure out why it sometimes fails with millisecond precision. Would probably
|
||||
# be useful to find the microsecond values where it consistently always fails.
|
||||
e1_ts = e1_ts.replace(microsecond=int(e1_ts.microsecond / 10000) * 100)
|
||||
e2_ts = e2_ts.replace(microsecond=int(e2_ts.microsecond / 10000) * 100)
|
||||
|
||||
e1 = Event(timestamp=e1_ts, data={"label": "test"})
|
||||
e2 = Event(timestamp=e2_ts, data={"label": "test"})
|
||||
|
||||
return e1, e2
|
||||
|
||||
|
||||
def _create_periodic_events(
|
||||
num_events, start=datetime.now(tz=timezone.utc), delta=timedelta(hours=1)
|
||||
):
|
||||
events = num_events * [None]
|
||||
|
||||
for i, dt in ((i, start + i * delta) for i in range(len(events))):
|
||||
events[i] = Event(timestamp=dt, duration=delta, data={"label": "test"})
|
||||
|
||||
return events
|
||||
|
||||
|
||||
def test_heartbeat(aw_client, bucket):
|
||||
bucket_id = bucket
|
||||
|
||||
e1, e2 = _create_heartbeat_events()
|
||||
|
||||
aw_client.heartbeat(bucket_id, e1, pulsetime=0)
|
||||
aw_client.heartbeat(bucket_id, e2, pulsetime=10)
|
||||
|
||||
event = aw_client.get_events(bucket_id, limit=1)[0]
|
||||
|
||||
assert event.timestamp == e1.timestamp
|
||||
assert event.duration == e2.timestamp - e1.timestamp
|
||||
|
||||
|
||||
def test_heartbeat_random_order(aw_client, bucket):
|
||||
bucket_id = bucket
|
||||
|
||||
# All the events will have the same data
|
||||
events = _create_periodic_events(100, delta=timedelta(seconds=1))
|
||||
random.shuffle(events)
|
||||
|
||||
for e in events:
|
||||
aw_client.heartbeat(bucket_id, e, pulsetime=2)
|
||||
|
||||
events = aw_client.get_events(bucket_id, limit=-1)
|
||||
|
||||
# FIXME: This should pass
|
||||
# assert len(events) == 1
|
||||
|
||||
|
||||
def test_queued_heartbeat(aw_client, queued_bucket):
|
||||
bucket_id = queued_bucket
|
||||
|
||||
e1, e2 = _create_heartbeat_events()
|
||||
|
||||
aw_client.heartbeat(bucket_id, e1, pulsetime=0, queued=True)
|
||||
aw_client.heartbeat(bucket_id, e2, pulsetime=10, queued=True)
|
||||
|
||||
# Needed because of aw_client-side heartbeat merging and delayed dispatch
|
||||
aw_client.heartbeat(
|
||||
bucket_id,
|
||||
Event(timestamp=e2.timestamp, data={"label": "something different"}),
|
||||
pulsetime=0,
|
||||
queued=True,
|
||||
)
|
||||
|
||||
# Needed since the dispatcher thread might introduce some delay
|
||||
max_tries = 20
|
||||
for i in range(max_tries):
|
||||
events = aw_client.get_events(bucket_id, limit=1)
|
||||
if len(events) > 0 and events[0].duration > timedelta(seconds=0):
|
||||
break
|
||||
sleep(0.5)
|
||||
|
||||
assert i != max_tries - 1
|
||||
print(f"Done on the {i + 1}th try")
|
||||
|
||||
assert len(events) == 1
|
||||
event = events[0]
|
||||
|
||||
assert event.timestamp == e1.timestamp
|
||||
assert event.duration == e2.timestamp - e1.timestamp
|
||||
|
||||
|
||||
def test_list_buckets(aw_client, bucket):
|
||||
buckets = aw_client.get_buckets()
|
||||
assert bucket in buckets.keys()
|
||||
|
||||
|
||||
def test_insert_event(aw_client, bucket):
|
||||
event = Event(timestamp=datetime.now(tz=timezone.utc), data={"label": "test"})
|
||||
aw_client.insert_event(bucket, event)
|
||||
recv_events = aw_client.get_events(bucket, limit=1)
|
||||
assert recv_events == [event]
|
||||
|
||||
|
||||
def test_insert_events(aw_client, bucket):
|
||||
events = _create_periodic_events(
|
||||
2, start=datetime.now(tz=timezone.utc) - timedelta(days=1)
|
||||
)
|
||||
|
||||
aw_client.insert_events(bucket, events)
|
||||
recv_events = aw_client.get_events(bucket)
|
||||
|
||||
# Why isn't reverse=True needed here?
|
||||
assert events == sorted(recv_events, key=lambda e: e.timestamp)
|
||||
|
||||
|
||||
def test_get_event_single(aw_client, bucket):
|
||||
start_dt = datetime.now(tz=timezone.utc) - timedelta(days=50)
|
||||
delta = timedelta(hours=1)
|
||||
events = _create_periodic_events(10, delta=delta, start=start_dt)
|
||||
|
||||
aw_client.insert_events(bucket, events)
|
||||
|
||||
events = aw_client.get_events(bucket)
|
||||
|
||||
for e in events:
|
||||
e2 = aw_client.get_event(bucket, e.id)
|
||||
assert e.id == e2.id
|
||||
assert e.timestamp == e2.timestamp
|
||||
assert e.duration == e2.duration
|
||||
assert e.data == e2.data
|
||||
|
||||
|
||||
def test_get_events_interval(aw_client, bucket):
|
||||
start_dt = datetime.now(tz=timezone.utc) - timedelta(days=50)
|
||||
end_dt = start_dt + timedelta(days=1)
|
||||
delta = timedelta(hours=1)
|
||||
events = _create_periodic_events(1000, delta=delta, start=start_dt)
|
||||
|
||||
aw_client.insert_events(bucket, events)
|
||||
|
||||
# start kwarg isn't currently range-inclusive
|
||||
recv_events = aw_client.get_events(bucket, limit=50, start=start_dt, end=end_dt)
|
||||
|
||||
assert len(recv_events) == 25
|
||||
|
||||
# drop IDs
|
||||
for e in recv_events:
|
||||
e.id = None
|
||||
print(recv_events)
|
||||
src_events = sorted(events[:25], reverse=True, key=lambda e: e.timestamp)
|
||||
|
||||
# The first event gets cut off
|
||||
assert recv_events[0].timestamp == src_events[0].timestamp
|
||||
assert recv_events[0].data == src_events[0].data
|
||||
|
||||
# This fails due to microsecond precision issues
|
||||
assert _round_td(recv_events[0].duration) == _round_td(
|
||||
end_dt - recv_events[0].timestamp
|
||||
)
|
||||
|
||||
# NOTE: Duration can differ by a few microseconds, so we round durations off to 10ms
|
||||
recv_events = [_round_durations(e) for e in recv_events]
|
||||
src_events = [_round_durations(e) for e in src_events]
|
||||
|
||||
assert recv_events[1:25] == src_events[1:25]
|
||||
|
||||
|
||||
def _round_td(td: timedelta) -> timedelta:
|
||||
return timedelta(seconds=round(td.total_seconds(), 2))
|
||||
|
||||
|
||||
def _round_durations(e: Event):
|
||||
e.duration = _round_td(e.duration)
|
||||
return e
|
||||
|
||||
|
||||
def test_store_many_events(aw_client, bucket):
|
||||
events = _create_periodic_events(
|
||||
1000, start=datetime.now(tz=timezone.utc) - timedelta(days=50)
|
||||
)
|
||||
|
||||
aw_client.insert_events(bucket, events)
|
||||
recv_events = aw_client.get_events(bucket, limit=-1)
|
||||
|
||||
assert len(events) == len(recv_events)
|
||||
assert recv_events == sorted(events, reverse=True, key=lambda e: e.timestamp)
|
||||
|
||||
|
||||
def test_midnight(aw_client, bucket):
|
||||
start_dt = datetime.now() - timedelta(days=1)
|
||||
midnight = start_dt.replace(hour=23, minute=50)
|
||||
events = _create_periodic_events(100, start=midnight, delta=timedelta(minutes=1))
|
||||
|
||||
aw_client.insert_events(bucket, events)
|
||||
recv_events = aw_client.get_events(bucket, limit=-1)
|
||||
assert len(recv_events) == len(events)
|
||||
|
||||
|
||||
def test_midnight_heartbeats(aw_client, bucket):
|
||||
now = datetime.now(tz=timezone.utc) - timedelta(days=1)
|
||||
# Zero out seconds/microseconds for deterministic midnight boundary.
|
||||
# Without this, residual microseconds cause non-deterministic boundary
|
||||
# matching when querying events around midnight (the endtime >= start_query
|
||||
# condition can include/exclude boundary events depending on microseconds).
|
||||
midnight = now.replace(hour=23, minute=50, second=0, microsecond=0)
|
||||
events = _create_periodic_events(20, start=midnight, delta=timedelta(minutes=1))
|
||||
|
||||
label_ring = ["1", "1", "2", "3", "4"]
|
||||
for i, e in enumerate(events):
|
||||
e.data["label"] = label_ring[i % len(label_ring)]
|
||||
aw_client.heartbeat(bucket, e, pulsetime=90)
|
||||
|
||||
recv_events_merged = aw_client.get_events(bucket, limit=-1)
|
||||
assert len(recv_events_merged) == 4 / 5 * len(events)
|
||||
|
||||
# Query from midnight (00:00), which is exactly midnight + 10 minutes from
|
||||
# the 23:50 start. Use a small offset to avoid boundary ambiguity where
|
||||
# events ending exactly at midnight could be included by the >= condition.
|
||||
query_start = midnight + timedelta(minutes=10, milliseconds=1)
|
||||
recv_events_after_midnight = aw_client.get_events(bucket, start=query_start)
|
||||
pprint(recv_events_after_midnight)
|
||||
assert len(recv_events_after_midnight) == int(len(recv_events_merged) / 2)
|
||||
|
||||
|
||||
def test_settings(aw_client):
|
||||
aw_client.set_setting("test", "test")
|
||||
aw_client.set_setting("list", json.dumps([1, 2, 3]))
|
||||
aw_client.set_setting("dict", json.dumps({"a": 1, "b": 2}))
|
||||
|
||||
# check set
|
||||
assert aw_client.get_setting("test") == "test"
|
||||
assert json.loads(aw_client.get_setting("list")) == [1, 2, 3]
|
||||
assert json.loads(aw_client.get_setting("dict")) == {"a": 1, "b": 2}
|
||||
|
||||
# check unset
|
||||
assert aw_client.get_setting("test2") is None
|
||||
|
||||
# check get all
|
||||
settings = aw_client.get_setting()
|
||||
assert settings["test"] == "test"
|
||||
assert json.loads(settings["list"]) == [1, 2, 3]
|
||||
assert json.loads(settings["dict"]) == {"a": 1, "b": 2}
|
||||
171
tests/test_extension_cors.py
Normal file
171
tests/test_extension_cors.py
Normal file
@@ -0,0 +1,171 @@
|
||||
"""Tests for moz-extension CORS endpoint scoping (extension_cors module)."""
|
||||
|
||||
import pytest
|
||||
|
||||
from aw_server.extension_cors import (
|
||||
_is_allowed,
|
||||
_is_allowed_path,
|
||||
_matches_configured_origin,
|
||||
)
|
||||
from aw_server.server import AWFlask
|
||||
|
||||
_EXT_ORIGIN = "moz-extension://aabbccddeeff00112233445566778899"
|
||||
_HOST = "127.0.0.1"
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def client():
|
||||
app = AWFlask(_HOST, testing=True)
|
||||
return app.test_client()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unit tests for the path-matching helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"method,path,expected",
|
||||
[
|
||||
("GET", "/api/0/info", True),
|
||||
("POST", "/api/0/buckets/aw-watcher-web-hostname", True),
|
||||
("POST", "/api/0/buckets/aw-watcher-web-hostname/heartbeat", True),
|
||||
# Blocked paths
|
||||
("GET", "/api/0/export", False),
|
||||
("POST", "/api/0/export", False),
|
||||
("GET", "/api/0/buckets/", False),
|
||||
("GET", "/api/0/buckets/aw-watcher-web-hostname/events", False),
|
||||
("POST", "/api/0/query/", False),
|
||||
("GET", "/api/0/settings", False),
|
||||
# Non-watcher bucket
|
||||
("POST", "/api/0/buckets/aw-watcher-window-hostname", False),
|
||||
# Heartbeat on non-watcher bucket
|
||||
("POST", "/api/0/buckets/aw-watcher-afk-hostname/heartbeat", False),
|
||||
# Percent-encoding should not bypass via raw path (segments are used)
|
||||
("GET", "/api/0/%65xport", False), # %65 = 'e', decodes to 'export'
|
||||
],
|
||||
)
|
||||
def test_is_allowed_unit(method, path, expected):
|
||||
segments = [s for s in path.split("/") if s]
|
||||
assert _is_allowed(method, segments) is expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"path,expected",
|
||||
[
|
||||
("/api/0/info", True),
|
||||
("/api/0/buckets/aw-watcher-web-hostname", True),
|
||||
("/api/0/buckets/aw-watcher-web-hostname/heartbeat", True),
|
||||
("/api/0/export", False),
|
||||
("/api/0/buckets/", False),
|
||||
],
|
||||
)
|
||||
def test_is_allowed_path_unit(path, expected):
|
||||
segments = [s for s in path.split("/") if s]
|
||||
assert _is_allowed_path(segments) is expected
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Integration tests via Flask test client
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"method,path",
|
||||
[
|
||||
("GET", "/api/0/info"),
|
||||
# Bucket creation: 400 without body is expected but NOT 403
|
||||
("POST", "/api/0/buckets/aw-watcher-web-testhost"),
|
||||
# Heartbeat: 404 (bucket doesn't exist) is expected but NOT 403
|
||||
("POST", "/api/0/buckets/aw-watcher-web-testhost/heartbeat"),
|
||||
],
|
||||
)
|
||||
def test_extension_allowed(client, method, path):
|
||||
"""moz-extension origins are permitted at aw-watcher-web's endpoints."""
|
||||
headers = {"Origin": _EXT_ORIGIN}
|
||||
r = client.open(path, method=method, headers=headers)
|
||||
assert (
|
||||
r.status_code != 403
|
||||
), f"{method} {path} should not be blocked; got {r.status_code}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"method,path",
|
||||
[
|
||||
("GET", "/api/0/export"),
|
||||
("GET", "/api/0/buckets/"),
|
||||
("POST", "/api/0/import"),
|
||||
("POST", "/api/0/query/"),
|
||||
("GET", "/api/0/settings"),
|
||||
# Events read from a watcher bucket
|
||||
("GET", "/api/0/buckets/aw-watcher-web-testhost/events"),
|
||||
# Non-watcher bucket
|
||||
("POST", "/api/0/buckets/aw-watcher-window-testhost"),
|
||||
("POST", "/api/0/buckets/aw-watcher-afk-testhost/heartbeat"),
|
||||
],
|
||||
)
|
||||
def test_extension_blocked(client, method, path):
|
||||
"""moz-extension origins are blocked at endpoints beyond aw-watcher-web's needs."""
|
||||
headers = {"Origin": _EXT_ORIGIN}
|
||||
r = client.open(path, method=method, headers=headers)
|
||||
assert (
|
||||
r.status_code == 403
|
||||
), f"{method} {path} should be blocked (403); got {r.status_code}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"pattern,origin,expected",
|
||||
[
|
||||
("moz-extension://aabbcc", "moz-extension://aabbcc", True),
|
||||
("MOZ-EXTENSION://AABBCC", "moz-extension://aabbcc", True),
|
||||
(r"moz-extension://.*", "moz-extension://aabbcc", True),
|
||||
(r"moz-extension://[a-f0-9]+", "moz-extension://aabbcc", True),
|
||||
(r"moz-extension://[0-9]+", "moz-extension://aabbcc", False),
|
||||
("moz-extension://other", "moz-extension://aabbcc", False),
|
||||
],
|
||||
)
|
||||
def test_matches_configured_origin(pattern, origin, expected):
|
||||
assert _matches_configured_origin(origin, pattern) is expected
|
||||
|
||||
|
||||
def test_regex_configured_extension_origin_bypasses_scope_guard():
|
||||
"""Owner-configured regex origins retain unrestricted endpoint access."""
|
||||
app = AWFlask(_HOST, testing=False, cors_origins=[r"moz-extension://.*"])
|
||||
client = app.test_client()
|
||||
|
||||
response = client.get("/api/0/export", headers={"Origin": _EXT_ORIGIN})
|
||||
|
||||
assert response.status_code != 403
|
||||
|
||||
|
||||
def test_non_extension_origin_passthrough(client):
|
||||
"""Non-moz-extension origins are not affected by the scope guard."""
|
||||
headers = {"Origin": "http://127.0.0.1:27180"}
|
||||
r = client.get("/api/0/info", headers=headers)
|
||||
assert r.status_code != 403
|
||||
|
||||
|
||||
def test_no_origin_passthrough(client):
|
||||
"""Requests without an Origin header (native watchers, curl) are not blocked."""
|
||||
r = client.get("/api/0/info")
|
||||
assert r.status_code != 403
|
||||
|
||||
|
||||
def test_options_allowed_path(client):
|
||||
"""OPTIONS preflight on an allowed path is permitted."""
|
||||
headers = {
|
||||
"Origin": _EXT_ORIGIN,
|
||||
"Access-Control-Request-Method": "GET",
|
||||
}
|
||||
r = client.options("/api/0/info", headers=headers)
|
||||
assert r.status_code != 403
|
||||
|
||||
|
||||
def test_options_blocked_path(client):
|
||||
"""OPTIONS preflight on a blocked path is rejected."""
|
||||
headers = {
|
||||
"Origin": _EXT_ORIGIN,
|
||||
"Access-Control-Request-Method": "GET",
|
||||
}
|
||||
r = client.options("/api/0/export", headers=headers)
|
||||
assert r.status_code == 403
|
||||
94
tests/test_profile.py
Normal file
94
tests/test_profile.py
Normal file
@@ -0,0 +1,94 @@
|
||||
"""Unit tests for profile resolution."""
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from aw_server.profile import (
|
||||
DEFAULT_PROFILE,
|
||||
TESTING_PROFILE,
|
||||
export_profile,
|
||||
is_testing,
|
||||
profile_from_env,
|
||||
profile_suffix,
|
||||
resolve_profile,
|
||||
validate_profile,
|
||||
)
|
||||
|
||||
|
||||
class TestResolveProfile:
|
||||
def test_no_flags_gives_default(self):
|
||||
assert resolve_profile(None, False) == DEFAULT_PROFILE
|
||||
|
||||
def test_testing_flag_is_an_alias(self):
|
||||
assert resolve_profile(None, True) == TESTING_PROFILE
|
||||
|
||||
def test_explicit_profile_wins(self):
|
||||
assert resolve_profile("research", False) == "research"
|
||||
|
||||
def test_testing_with_matching_profile_is_allowed(self):
|
||||
assert resolve_profile("testing", True) == TESTING_PROFILE
|
||||
|
||||
def test_testing_with_conflicting_profile_raises(self):
|
||||
with pytest.raises(ValueError, match="conflicts"):
|
||||
resolve_profile("research", True)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"name", ["Research", "with space", "a" * 33, "", "../escape", "-leading"]
|
||||
)
|
||||
def test_invalid_names_rejected(self, name):
|
||||
with pytest.raises(ValueError):
|
||||
validate_profile(name)
|
||||
|
||||
@pytest.mark.parametrize("name", ["research", "aw2", "my_profile", "my-profile"])
|
||||
def test_valid_names_accepted(self, name):
|
||||
assert validate_profile(name) == name
|
||||
|
||||
|
||||
class TestProfileSuffix:
|
||||
def test_default_has_no_suffix(self):
|
||||
assert profile_suffix(DEFAULT_PROFILE) == ""
|
||||
|
||||
def test_testing_keeps_legacy_suffix(self):
|
||||
assert profile_suffix(TESTING_PROFILE) == "-testing"
|
||||
|
||||
def test_custom_profile_suffix(self):
|
||||
assert profile_suffix("research") == "-research"
|
||||
|
||||
def test_suffixes_are_disjoint(self):
|
||||
suffixes = {profile_suffix(p) for p in ("default", "testing", "research")}
|
||||
assert len(suffixes) == 3
|
||||
|
||||
|
||||
class TestIsTesting:
|
||||
def test_only_testing_profile_is_testing(self):
|
||||
assert is_testing(TESTING_PROFILE)
|
||||
assert not is_testing(DEFAULT_PROFILE)
|
||||
assert not is_testing("research")
|
||||
|
||||
|
||||
class TestExportProfile:
|
||||
def test_exported_for_child_processes(self, monkeypatch):
|
||||
monkeypatch.delenv("AW_PROFILE", raising=False)
|
||||
export_profile("research")
|
||||
assert os.environ["AW_PROFILE"] == "research"
|
||||
|
||||
def test_default_unsets_env_so_aw_core_keeps_bare_root(self, monkeypatch):
|
||||
monkeypatch.setenv("AW_PROFILE", "research")
|
||||
export_profile(DEFAULT_PROFILE)
|
||||
assert "AW_PROFILE" not in os.environ
|
||||
|
||||
|
||||
class TestProfileFromEnv:
|
||||
def test_defaults_when_unset(self, monkeypatch):
|
||||
monkeypatch.delenv("AW_PROFILE", raising=False)
|
||||
assert profile_from_env(False) == DEFAULT_PROFILE
|
||||
assert profile_from_env(True) == TESTING_PROFILE
|
||||
|
||||
def test_reads_exported_profile(self, monkeypatch):
|
||||
monkeypatch.setenv("AW_PROFILE", "research")
|
||||
assert profile_from_env(False) == "research"
|
||||
|
||||
def test_ignores_invalid_env_value(self, monkeypatch):
|
||||
monkeypatch.setenv("AW_PROFILE", "Not A Profile")
|
||||
assert profile_from_env(False) == DEFAULT_PROFILE
|
||||
127
tests/test_profile_config.py
Normal file
127
tests/test_profile_config.py
Normal file
@@ -0,0 +1,127 @@
|
||||
"""Port, settings, and CLI wiring for named profiles."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
from aw_server.config import config_section, default_port
|
||||
from aw_server.main import parse_settings
|
||||
from aw_server.profile import DEFAULT_PROFILE, TESTING_PROFILE, export_profile
|
||||
from aw_server.settings import Settings
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def xdg_tmp(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "config"))
|
||||
monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path / "data"))
|
||||
monkeypatch.setenv("XDG_CACHE_HOME", str(tmp_path / "cache"))
|
||||
monkeypatch.delenv("AW_PROFILE", raising=False)
|
||||
return tmp_path
|
||||
|
||||
|
||||
class TestConfigHelpers:
|
||||
def test_sections_are_disjoint(self):
|
||||
sections = {config_section(p) for p in ("default", "testing", "research")}
|
||||
assert sections == {"server", "server-testing", "server-research"}
|
||||
|
||||
def test_default_ports(self):
|
||||
assert default_port(DEFAULT_PROFILE) == 5600
|
||||
assert default_port(TESTING_PROFILE) == 5666
|
||||
assert default_port("research") == 5600
|
||||
|
||||
|
||||
class TestSettingsFilename:
|
||||
def test_testing_keeps_legacy_name(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"aw_server.settings.get_config_dir", lambda module: str(tmp_path)
|
||||
)
|
||||
monkeypatch.delenv("AW_PROFILE", raising=False)
|
||||
settings = Settings(True)
|
||||
assert settings.config_file.name == "settings-testing.json"
|
||||
|
||||
def test_default_unsuffixed(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"aw_server.settings.get_config_dir", lambda module: str(tmp_path)
|
||||
)
|
||||
monkeypatch.delenv("AW_PROFILE", raising=False)
|
||||
settings = Settings(False)
|
||||
assert settings.config_file.name == "settings.json"
|
||||
|
||||
def test_named_profile_suffix(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"aw_server.settings.get_config_dir", lambda module: str(tmp_path)
|
||||
)
|
||||
monkeypatch.setenv("AW_PROFILE", "research")
|
||||
settings = Settings(False)
|
||||
assert settings.config_file.name == "settings-research.json"
|
||||
|
||||
|
||||
class TestParseSettings:
|
||||
def test_testing_flag_selects_testing_port(self, xdg_tmp, monkeypatch):
|
||||
monkeypatch.setattr(sys, "argv", ["aw-server", "--testing"])
|
||||
settings, _storage = parse_settings()
|
||||
assert settings.testing is True
|
||||
assert settings.profile == TESTING_PROFILE
|
||||
assert settings.port == 5666
|
||||
assert os.environ["AW_PROFILE"] == "testing"
|
||||
|
||||
def test_profile_testing_is_alias_for_testing_flag(self, xdg_tmp, monkeypatch):
|
||||
monkeypatch.setattr(sys, "argv", ["aw-server", "--profile", "testing"])
|
||||
settings, _storage = parse_settings()
|
||||
assert settings.testing is True
|
||||
assert settings.profile == TESTING_PROFILE
|
||||
assert settings.port == 5666
|
||||
|
||||
def test_default_keeps_port_5600_and_unsets_env(self, xdg_tmp, monkeypatch):
|
||||
monkeypatch.setenv("AW_PROFILE", "research")
|
||||
monkeypatch.setattr(sys, "argv", ["aw-server"])
|
||||
settings, _storage = parse_settings()
|
||||
assert settings.testing is False
|
||||
assert settings.profile == DEFAULT_PROFILE
|
||||
assert settings.port == 5600
|
||||
assert "AW_PROFILE" not in os.environ
|
||||
|
||||
def test_named_profile_exports_env_and_falls_back_to_server_section(
|
||||
self, xdg_tmp, monkeypatch
|
||||
):
|
||||
monkeypatch.setattr(sys, "argv", ["aw-server", "--profile", "research"])
|
||||
settings, _storage = parse_settings()
|
||||
assert settings.testing is False
|
||||
assert settings.profile == "research"
|
||||
assert settings.port == 5600
|
||||
assert os.environ["AW_PROFILE"] == "research"
|
||||
|
||||
def test_cli_port_override(self, xdg_tmp, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
sys, "argv", ["aw-server", "--profile", "research", "--port", "5667"]
|
||||
)
|
||||
settings, _storage = parse_settings()
|
||||
assert settings.port == 5667
|
||||
assert settings.profile == "research"
|
||||
|
||||
def test_conflicting_flags_are_a_usage_error(self, xdg_tmp, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
sys, "argv", ["aw-server", "--testing", "--profile", "research"]
|
||||
)
|
||||
with pytest.raises(SystemExit):
|
||||
parse_settings()
|
||||
|
||||
def test_invalid_profile_is_a_usage_error(self, xdg_tmp, monkeypatch):
|
||||
monkeypatch.setattr(sys, "argv", ["aw-server", "--profile", "Research"])
|
||||
with pytest.raises(SystemExit):
|
||||
parse_settings()
|
||||
|
||||
|
||||
def test_named_profile_config_is_isolated_from_default(xdg_tmp, monkeypatch):
|
||||
"""AW_PROFILE must be exported before load_config, else both profiles
|
||||
share ~/.config/activitywatch/aw-server."""
|
||||
import aw_core.dirs as dirs
|
||||
|
||||
export_profile("research")
|
||||
research_dir = dirs.get_config_dir("aw-server")
|
||||
export_profile(DEFAULT_PROFILE)
|
||||
default_dir = dirs.get_config_dir("aw-server")
|
||||
assert research_dir != default_dir
|
||||
assert "activitywatch-research" in research_dir
|
||||
assert "activitywatch-research" not in default_dir
|
||||
184
tests/test_server.py
Normal file
184
tests/test_server.py
Normal file
@@ -0,0 +1,184 @@
|
||||
import random
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def bucket(flask_client):
|
||||
"Context manager for creating and deleting a testing bucket"
|
||||
try:
|
||||
bucket_id = "test"
|
||||
r = flask_client.post(
|
||||
f"/api/0/buckets/{bucket_id}",
|
||||
json={"client": "test", "type": "test", "hostname": "test"},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
yield bucket_id
|
||||
finally:
|
||||
r = flask_client.delete(f"/api/0/buckets/{bucket_id}")
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
def test_info(flask_client):
|
||||
r = flask_client.get("/api/0/info")
|
||||
assert r.status_code == 200
|
||||
assert r.json["testing"]
|
||||
assert r.json["profile"] == "testing"
|
||||
|
||||
|
||||
def test_buckets(flask_client, bucket, benchmark):
|
||||
@benchmark
|
||||
def list_buckets():
|
||||
r = flask_client.get("/api/0/buckets/")
|
||||
print(r.json)
|
||||
assert r.status_code == 200
|
||||
assert len(r.json) == 1
|
||||
|
||||
|
||||
def test_heartbeats(flask_client, bucket, benchmark):
|
||||
# FIXME: Currently tests using the memory storage method
|
||||
# TODO: Test with a longer data section and see if there's a significant difference
|
||||
# TODO: Test with a larger bucket and see if there's a significant difference
|
||||
@benchmark
|
||||
def heartbeat():
|
||||
now = datetime.now()
|
||||
r = flask_client.post(
|
||||
f"/api/0/buckets/{bucket}/heartbeat?pulsetime=1",
|
||||
json={"timestamp": now, "duration": 0, "data": {"random": random.random()}},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
def test_get_events(flask_client, bucket, benchmark):
|
||||
n_events = 100
|
||||
start_time = datetime.now() - timedelta(days=100)
|
||||
for i in range(n_events):
|
||||
now = start_time + timedelta(hours=i)
|
||||
r = flask_client.post(
|
||||
f"/api/0/buckets/{bucket}/heartbeat?pulsetime=0",
|
||||
json={"timestamp": now, "duration": 0, "data": {"random": random.random()}},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
|
||||
@benchmark
|
||||
def get_events():
|
||||
r = flask_client.get(f"/api/0/buckets/{bucket}/events")
|
||||
assert r.status_code == 200
|
||||
assert r.json
|
||||
assert len(r.json) == n_events
|
||||
|
||||
r = flask_client.get(f"/api/0/buckets/{bucket}/events?limit=-1")
|
||||
assert r.status_code == 200
|
||||
assert r.json
|
||||
assert len(r.json) == n_events
|
||||
|
||||
r = flask_client.get(f"/api/0/buckets/{bucket}/events?limit=10")
|
||||
assert r.status_code == 200
|
||||
assert r.json
|
||||
assert len(r.json) == 10
|
||||
|
||||
r = flask_client.get(f"/api/0/buckets/{bucket}/events?limit=100")
|
||||
assert r.status_code == 200
|
||||
assert r.json
|
||||
assert len(r.json) == n_events
|
||||
|
||||
r = flask_client.get(f"/api/0/buckets/{bucket}/events?limit=1000")
|
||||
assert r.status_code == 200
|
||||
assert r.json
|
||||
assert len(r.json) == n_events
|
||||
|
||||
|
||||
def test_insert_event_returns_list(flask_client, bucket):
|
||||
"""Test that POST /events returns a list of events with IDs (matching aw-server-rust)."""
|
||||
now = datetime.now()
|
||||
event_data = {
|
||||
"timestamp": now.isoformat(),
|
||||
"duration": 0,
|
||||
"data": {"label": "test"},
|
||||
}
|
||||
|
||||
# Single event as list
|
||||
r = flask_client.post(
|
||||
f"/api/0/buckets/{bucket}/events",
|
||||
json=[event_data],
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert isinstance(r.json, list), f"Expected list, got {type(r.json)}"
|
||||
assert len(r.json) == 1
|
||||
assert r.json[0]["id"] is not None
|
||||
assert r.json[0]["data"] == {"label": "test"}
|
||||
|
||||
# Single event as dict (legacy format)
|
||||
r = flask_client.post(
|
||||
f"/api/0/buckets/{bucket}/events",
|
||||
json=event_data,
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert isinstance(r.json, list), f"Expected list, got {type(r.json)}"
|
||||
assert len(r.json) == 1
|
||||
assert r.json[0]["id"] is not None
|
||||
|
||||
|
||||
def test_insert_events_returns_list(flask_client, bucket):
|
||||
"""Test that POST /events with multiple events returns a list."""
|
||||
now = datetime.now()
|
||||
events_data = [
|
||||
{
|
||||
"timestamp": (now - timedelta(hours=i)).isoformat(),
|
||||
"duration": 0,
|
||||
"data": {"label": f"test-{i}"},
|
||||
}
|
||||
for i in range(3)
|
||||
]
|
||||
|
||||
r = flask_client.post(
|
||||
f"/api/0/buckets/{bucket}/events",
|
||||
json=events_data,
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert isinstance(r.json, list), f"Expected list, got {type(r.json)}"
|
||||
assert len(r.json) == 0
|
||||
|
||||
|
||||
# TODO: Add benchmark for basic AFK-filtering query
|
||||
|
||||
|
||||
def test_query_invalid_timeperiod(flask_client):
|
||||
"""Malformed timeperiods must yield 400 (client error), not 500.
|
||||
|
||||
Regression test: a non-ISO8601 timeperiod previously raised an uncaught
|
||||
iso8601.ParseError, surfacing as an Internal Server Error.
|
||||
"""
|
||||
r = flask_client.post(
|
||||
"/api/0/query/",
|
||||
json={"query": ["RETURN = 1;"], "timeperiods": ["not-a-valid-period"]},
|
||||
)
|
||||
assert r.status_code == 400
|
||||
assert "not-a-valid-period" in r.json["message"]
|
||||
|
||||
|
||||
def test_query_timeperiod_missing_slash(flask_client):
|
||||
"""A timeperiod without a start/end slash separator must yield 400, not 500.
|
||||
|
||||
Regression test: a single ISO8601 datetime (no slash) previously raised an
|
||||
uncaught IndexError when indexing the split result.
|
||||
"""
|
||||
r = flask_client.post(
|
||||
"/api/0/query/",
|
||||
json={"query": ["RETURN = 1;"], "timeperiods": ["2024-01-01T00:00:00+00:00"]},
|
||||
)
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
def test_query_valid_timeperiod(flask_client):
|
||||
"""A well-formed query with a valid timeperiod still succeeds."""
|
||||
r = flask_client.post(
|
||||
"/api/0/query/",
|
||||
json={
|
||||
"query": ["RETURN = 1;"],
|
||||
"timeperiods": ["2024-01-01T00:00:00+00:00/2024-01-02T00:00:00+00:00"],
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert r.json == [1]
|
||||
Reference in New Issue
Block a user