Skip to content

Commit 7ea201c

Browse files
authored
Merge branch 'master' into claude/plain-run-shell-ux-wdcwrh
2 parents 49d7a27 + a1f12bf commit 7ea201c

62 files changed

Lines changed: 2954 additions & 488 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.claude/rules/plain-code.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ Runs ruff, ty (type checking), oxlint/oxfmt, and annotation coverage checks with
2828

2929
## Code Style
3030

31-
- Add `from __future__ import annotations` at the top of Python files
31+
- Functions with more than one parameter should make them keyword-only (`def send(*, to, subject)`) — explicit call sites, safer refactors
32+
- Don't accept or pass through `**kwargs` blindly — spell out real parameters so type checking works at call sites
3233
- Keep imports at the top of the file unless avoiding circular imports
3334
- Don't include args/returns in docstrings if already type annotated

.claude/rules/plain.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,8 @@ If the exception propagates out of the span context, the SDK auto-records and se
8383
- View 5xx attachment — `plain/views/base.py:_respond_to_exception` (records on the SERVER span via `_finalize_span`)
8484
- Job enqueue — PRODUCER (`plain-jobs/jobs/jobs.py`)
8585
- Job execute — CONSUMER (`plain-jobs/jobs/models.py`), plus a fallback CONSUMER span in `plain-jobs/jobs/workers.py:process_job` that catches lookup-time failures before `run()` is reached
86-
- Worker maintenance loop — CONSUMER (`plain-jobs/jobs/workers.py`)
86+
- Worker maintenance loop — CONSUMER (`plain-jobs/jobs/workers.py`), opened only on ticks where a maintenance task is due — fully idle ticks emit no spans at all
87+
- Worker claim/heartbeat failures — one-off `claim job` / `worker heartbeat` CONSUMER error spans via `emit_error_consumer_span` (`plain-jobs/jobs/otel.py`); the claim transaction, heartbeat writes, gauge queries, and done-callback bookkeeping are otherwise untraced (`plain.postgres.otel.suppress_db_tracing`)
8788
- Chore execution — CONSUMER (`plain/cli/chores.py`)
8889
- MCP RPC dispatch — SERVER (`plain-mcp/mcp/views.py`)
8990

plain-admin/tests/app/urls.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from plain.admin.urls import AdminRouter
22
from plain.assets.urls import AssetsRouter
3+
from plain.http import Response
34
from plain.urls import Router, include, path
45
from plain.views import View
56

@@ -9,6 +10,13 @@ def get(self):
910
return "Login!"
1011

1112

13+
class WhoView(View):
14+
"""A plain 200 endpoint for reading the effective request user in tests."""
15+
16+
def get(self):
17+
return Response("ok")
18+
19+
1220
class LogoutView(View):
1321
def get(self):
1422
return "Logout!"
@@ -21,4 +29,5 @@ class AppRouter(Router):
2129
include("assets", AssetsRouter),
2230
path("login", LoginView, name="login"),
2331
path("logout", LogoutView, name="logout"),
32+
path("whoami", WhoView, name="whoami"),
2433
]
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
"""Tests for the admin date-range helpers used by cards and charts."""
2+
3+
from __future__ import annotations
4+
5+
import datetime
6+
7+
import pytest
8+
9+
from plain.admin.dates import DatetimeRange, DatetimeRangeAliases
10+
11+
12+
def _dt(y, m, d, **kw):
13+
return datetime.datetime(y, m, d, tzinfo=datetime.UTC, **kw)
14+
15+
16+
def test_as_tuple_and_total_days():
17+
r = DatetimeRange(start=_dt(2024, 1, 1), end=_dt(2024, 1, 4))
18+
assert r.as_tuple() == (_dt(2024, 1, 1), _dt(2024, 1, 4))
19+
assert r.total_days() == 3
20+
21+
22+
def test_iter_days_is_inclusive():
23+
r = DatetimeRange(start=_dt(2024, 1, 1), end=_dt(2024, 1, 3))
24+
days = list(r.iter_days())
25+
assert days == [
26+
datetime.date(2024, 1, 1),
27+
datetime.date(2024, 1, 2),
28+
datetime.date(2024, 1, 3),
29+
]
30+
31+
32+
def test_contains_checks_bounds():
33+
r = DatetimeRange(start=_dt(2024, 1, 1), end=_dt(2024, 1, 31))
34+
assert _dt(2024, 1, 15) in r
35+
assert _dt(2023, 12, 31) not in r
36+
assert _dt(2024, 2, 1) not in r
37+
38+
39+
def test_equality_and_hash():
40+
a = DatetimeRange(start=_dt(2024, 1, 1), end=_dt(2024, 1, 2))
41+
b = DatetimeRange(start=_dt(2024, 1, 1), end=_dt(2024, 1, 2))
42+
c = DatetimeRange(start=_dt(2024, 1, 1), end=_dt(2024, 1, 3))
43+
assert a == b
44+
assert a != c
45+
assert hash(a) == hash(b)
46+
47+
48+
def test_from_value_round_trip():
49+
assert DatetimeRangeAliases.from_value("Today") is DatetimeRangeAliases.TODAY
50+
51+
52+
def test_from_value_rejects_unknown():
53+
with pytest.raises(ValueError, match="not a valid value"):
54+
DatetimeRangeAliases.from_value("Some Nonsense Range")
55+
56+
57+
def test_to_range_today_spans_a_single_day():
58+
r = DatetimeRangeAliases.to_range("Today")
59+
assert (r.start.hour, r.start.minute, r.start.second) == (0, 0, 0)
60+
assert (r.end.hour, r.end.minute) == (23, 59)
61+
assert r.start.date() == r.end.date()
62+
assert r.total_days() == 0
63+
64+
65+
def test_to_range_accepts_enum_member():
66+
r = DatetimeRangeAliases.to_range(DatetimeRangeAliases.SINCE_30_DAYS_AGO)
67+
# "Since 30 days ago" runs from 30 days back through today.
68+
assert 29 <= r.total_days() <= 30
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
"""Security tests for admin user impersonation.
2+
3+
Impersonation lets an allowed user (by default, an admin) act as another
4+
user. The guardrails that matter: only allowed users can start it, admins
5+
can't be impersonated, and stopping restores the original user.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
from app.users.models import User
11+
12+
from plain.test import Client
13+
14+
15+
def test_admin_can_impersonate_regular_user(db):
16+
admin = User.query.create(username="admin", is_admin=True)
17+
target = User.query.create(username="target", is_admin=False)
18+
19+
client = Client()
20+
client.force_login(admin)
21+
22+
started = client.get(f"/admin/impersonate/start/{target.id}")
23+
assert started.status_code == 302
24+
25+
# Subsequent requests are now served as the target user.
26+
response = client.get("/whoami")
27+
assert response.user.id == target.id
28+
29+
30+
def test_stopping_impersonation_restores_original_user(db):
31+
admin = User.query.create(username="admin", is_admin=True)
32+
target = User.query.create(username="target", is_admin=False)
33+
34+
client = Client()
35+
client.force_login(admin)
36+
client.get(f"/admin/impersonate/start/{target.id}")
37+
assert client.get("/whoami").user.id == target.id
38+
39+
stopped = client.get("/admin/impersonate/stop")
40+
assert stopped.status_code == 302
41+
42+
# Back to acting as the admin.
43+
assert client.get("/whoami").user.id == admin.id
44+
45+
46+
def test_non_admin_cannot_start_impersonation(db):
47+
regular = User.query.create(username="regular", is_admin=False)
48+
target = User.query.create(username="target", is_admin=False)
49+
50+
client = Client()
51+
client.force_login(regular)
52+
53+
started = client.get(f"/admin/impersonate/start/{target.id}")
54+
assert started.status_code == 403
55+
56+
# The effective user is unchanged — no impersonation took hold.
57+
assert client.get("/whoami").user.id == regular.id
58+
59+
60+
def test_admin_users_cannot_be_impersonated(db):
61+
admin = User.query.create(username="admin", is_admin=True)
62+
other_admin = User.query.create(username="other_admin", is_admin=True)
63+
64+
client = Client()
65+
client.force_login(admin)
66+
67+
# The start view sets the session marker, but the middleware refuses to
68+
# swap to an admin target and clears it.
69+
assert client.get(f"/admin/impersonate/start/{other_admin.id}").status_code == 302
70+
71+
blocked = client.get("/whoami")
72+
assert blocked.status_code == 403
73+
74+
# After the refusal the marker is cleared, so normal requests resume.
75+
assert client.get("/whoami").user.id == admin.id

plain-auth/tests/app/urls.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
1+
from app.users.models import User
2+
3+
from plain.auth import login, logout
14
from plain.auth.views import AuthView
25
from plain.http import Response
6+
from plain.sessions import get_request_session
37
from plain.urls import Router, path
48
from plain.views import View
59

@@ -9,6 +13,39 @@ def get(self):
913
return Response("login")
1014

1115

16+
class VisitView(View):
17+
"""Write to the anonymous session so a session cookie is issued."""
18+
19+
def get(self):
20+
get_request_session(self.request)["visited"] = "yes"
21+
return Response("visited")
22+
23+
24+
class SessionLoginView(View):
25+
"""Log a user in through the real ``login()`` flow."""
26+
27+
def post(self):
28+
user = User.query.get(id=self.request.form_data["user_id"])
29+
login(self.request, user)
30+
return Response("logged in")
31+
32+
33+
class SessionLogoutView(View):
34+
"""Log the current user out through the real ``logout()`` flow."""
35+
36+
def post(self):
37+
logout(self.request)
38+
return Response("logged out")
39+
40+
41+
class WhoView(AuthView):
42+
login_required = True
43+
44+
def get(self):
45+
# login_required guarantees an authenticated user here.
46+
return Response(self.user.username) # ty: ignore[unresolved-attribute]
47+
48+
1249
class ProtectedView(AuthView):
1350
login_required = True
1451

@@ -42,6 +79,10 @@ class AppRouter(Router):
4279
namespace = ""
4380
urls = [
4481
path("login", LoginView, name="login"),
82+
path("visit", VisitView, name="visit"),
83+
path("session-login", SessionLoginView, name="session_login"),
84+
path("session-logout", SessionLogoutView, name="session_logout"),
85+
path("whoami", WhoView, name="whoami"),
4586
path("protected", ProtectedView, name="protected"),
4687
path("open", OpenView, name="open"),
4788
path("admin", AdminView, name="admin"),
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
"""End-to-end tests for the real ``login()`` / ``logout()`` session flow.
2+
3+
These exercise the functions users actually call from their login views —
4+
covering session persistence across requests, session-fixation protection,
5+
logout, and safe user-switching — rather than the test-only ``force_login``
6+
shortcut.
7+
"""
8+
9+
from app.users.models import User
10+
11+
from plain.runtime import settings
12+
from plain.test import Client
13+
14+
SESSION_COOKIE = settings.SESSION_COOKIE_NAME
15+
16+
17+
def _session_cookie(client):
18+
morsel = client.cookies.get(SESSION_COOKIE)
19+
return morsel.value if morsel else None
20+
21+
22+
def test_login_persists_across_requests(db):
23+
user = User.query.create(username="alice")
24+
client = Client()
25+
26+
# A protected page is unreachable before logging in.
27+
assert client.get("/whoami").status_code == 302
28+
29+
resp = client.post("/session-login", data={"user_id": user.id})
30+
assert resp.status_code == 200
31+
32+
# The same client is now recognized on a later, separate request.
33+
resp = client.get("/whoami")
34+
assert resp.status_code == 200
35+
assert resp.content == b"alice"
36+
37+
38+
def test_login_rotates_session_key(db):
39+
"""Logging in from an anonymous session issues a new session key
40+
(session-fixation protection)."""
41+
user = User.query.create(username="bob")
42+
client = Client()
43+
44+
# Establish an anonymous session (with data) so a cookie already exists.
45+
client.get("/visit")
46+
anon_key = _session_cookie(client)
47+
assert anon_key is not None
48+
49+
client.post("/session-login", data={"user_id": user.id})
50+
logged_in_key = _session_cookie(client)
51+
52+
assert logged_in_key is not None
53+
assert logged_in_key != anon_key
54+
55+
56+
def test_logout_flushes_session(db):
57+
user = User.query.create(username="carol")
58+
client = Client()
59+
60+
client.post("/session-login", data={"user_id": user.id})
61+
assert client.get("/whoami").status_code == 200
62+
63+
resp = client.post("/session-logout")
64+
assert resp.status_code == 200
65+
66+
# After logout the protected page redirects to login again.
67+
assert client.get("/whoami").status_code == 302
68+
69+
70+
def test_login_as_different_user_replaces_session(db):
71+
"""Logging in as a second user must not retain the first user's session."""
72+
first = User.query.create(username="first")
73+
second = User.query.create(username="second")
74+
client = Client()
75+
76+
client.post("/session-login", data={"user_id": first.id})
77+
assert client.get("/whoami").content == b"first"
78+
79+
client.post("/session-login", data={"user_id": second.id})
80+
assert client.get("/whoami").content == b"second"

plain-cloud/pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@ readme = "README.md"
88
requires-python = ">=3.13"
99
dependencies = ["click>=8.0.0", "httpx>=0.27.0", "keyring>=24.0.0"]
1010

11+
[dependency-groups]
12+
dev = ["pytest"]
13+
1114
[project.scripts]
1215
plain-cloud = "plain.cloud.cli:cli"
1316
plaincloud = "plain.cloud.cli:cli"

plain-code/plain/code/agents/.claude/rules/plain-code.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ Runs ruff, ty (type checking), oxlint/oxfmt, and annotation coverage checks with
2828

2929
## Code Style
3030

31-
- Add `from __future__ import annotations` at the top of Python files
31+
- Functions with more than one parameter should make them keyword-only (`def send(*, to, subject)`) — explicit call sites, safer refactors
32+
- Don't accept or pass through `**kwargs` blindly — spell out real parameters so type checking works at call sites
3233
- Keep imports at the top of the file unless avoiding circular imports
3334
- Don't include args/returns in docstrings if already type annotated

plain-dev/plain/dev/core.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,12 @@ def run(self, *, reinstall_ssl: bool = False) -> int:
202202
self.add_entrypoints()
203203
self.add_pyproject_run()
204204

205+
# The status bar is invisible in piped output and the log file,
206+
# so print the URLs as regular lines too.
207+
self.poncho.system_print(f"Server running at {self.url}\n")
208+
if self.tunnel_url:
209+
self.poncho.system_print(f"Tunnel running at {self.tunnel_url}\n")
210+
205211
try:
206212
# Start processes we know about and block the main thread
207213
self.poncho.loop()

0 commit comments

Comments
 (0)