Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 11 additions & 5 deletions src/profiles/server/routes/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,7 @@ def _apply_config_from_disk(state: ServerState, config_path: Path) -> dict:
loaded = ConfigReader(config_path).load()
state.config = loaded
state.session_trial = False
state.trial_content = None
state.config_path = config_path
state.active_directory = _choose_active_directory(loaded, config_path)
return _config_response(state)
Expand Down Expand Up @@ -264,6 +265,9 @@ async def load_content_endpoint(payload: LoadContentRequest, request: Request) -

state.config = loaded
state.session_trial = True
# Remember the raw payload (issue #97): parsing discards it, and it is
# the only record GET /content can echo for a path-less trial.
state.trial_content = payload.content
state.active_directory = _choose_active_directory(loaded, None)
response = _config_response(state)
response["session_trial"] = True
Expand Down Expand Up @@ -341,6 +345,7 @@ async def reload_config_endpoint(request: Request) -> dict:
loaded.config_path = config_path
state.config = loaded
state.session_trial = False
state.trial_content = None
state.config_path = config_path
state.active_directory = _choose_active_directory(loaded, config_path)
response = _config_response(state)
Expand All @@ -364,17 +369,18 @@ async def get_config_content(request: Request) -> dict:
"""Return the active configuration file's text for the YAML editor.

Responds ``{"filename", "content", "config_path", "session_trial"}``.
A session trial (config applied from dropped content) answers an
empty body flagged ``session_trial=True`` — ``config_path`` stays
``None`` so the stale CWD-resolved path never leaks, and ``filename``
carries the config's display name — instead of file text; a
A session trial (config applied from dropped content) answers the
text that was applied (``ServerState.trial_content``, issue #97) with
``session_trial=True`` and ``config_path=None`` — ``config_path``
stays ``None`` so the stale CWD-resolved path never leaks, and
``filename`` carries the config's display name. A
recorded-but-missing file answers 404.
"""
state: ServerState = request.app.state.server_state
if not has_disk_path(state):
return {
"filename": state.config.title or "session-trial",
"content": "",
"content": state.trial_content or "",
"config_path": None,
"session_trial": True,
}
Expand Down
6 changes: 6 additions & 0 deletions src/profiles/server/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,12 @@ class ServerState:
# True while the active config is an in-memory trial from a
# dropped/loaded content payload (no disk anchor).
session_trial: bool = False
# Raw YAML text behind ``session_trial`` trials (issue #97): the ONLY
# record of what was applied, since parsing discards the payload.
# GET /content echoes it so a reopened editor shows the live trial
# configuration. Cleared by every disk-anchoring path — it belongs to
# no real file, so anchoring invalidates it.
trial_content: str | None = None
# Shared secret verifying handoff requests from a second ProFiles
# launch (issue #4); None disables the identity/handoff endpoints.
instance_token: str | None = None
Expand Down
44 changes: 43 additions & 1 deletion tests/server/routes/test_config_editor.py
Original file line number Diff line number Diff line change
Expand Up @@ -130,12 +130,17 @@ async def test_content_flags_session_trial_after_live_drop(
Ruling R4: ``_config_response`` re-records a resolved path into the
state even for a drop, so trial detection must ride the explicit
``ServerState.session_trial`` flag — not path absence.

The endpoint echoes the applied YAML text (issue #97): an editor
reopened after an Apply must show the live in-memory configuration,
not an empty body.
"""
trial_text = "defaults:\n title: Dropped\n extensions:\n - .mttl\n"
app = create_app(config_state)
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
drop = await client.post(
"/api/config/load-content",
json={"filename": "my.profiles", "content": "defaults:\n title: Dropped\n"},
json={"filename": "my.profiles", "content": trial_text},
)
assert drop.status_code == 200
assert drop.json()["session_trial"] is True
Expand All @@ -149,6 +154,43 @@ async def test_content_flags_session_trial_after_live_drop(
assert payload["session_trial"] is True
assert payload["config_path"] is None
assert payload["filename"] == "Dropped"
assert payload["content"] == trial_text


@pytest.mark.anyio
async def test_content_returns_disk_text_after_trial_is_anchored(
config_state: ServerState, tmp_path: Path
) -> None:
"""Verify anchoring the session to disk clears the stored trial text.

Given: A session holding a session-trial config (applied content)
When: POST /load anchors a real disk config file
Then: GET /content returns the disk file text with session_trial
cleared — the trial content is dropped, never leaked into the
disk-anchored response
"""
disk_path = tmp_path / ".profiles"
disk_text = 'defaults:\n title: "On Disk"\n'
disk_path.write_text(disk_text, encoding="utf-8")

app = create_app(config_state)
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
drop = await client.post(
"/api/config/load-content",
json={"filename": "my.profiles", "content": "defaults:\n title: Dropped\n"},
)
assert drop.status_code == 200

load = await client.post("/api/config/load", json={"config_path": str(disk_path)})
assert load.status_code == 200

response = await client.get("/api/config/content")

assert response.status_code == 200
payload = response.json()
assert payload["session_trial"] is False
assert payload["config_path"] == str(disk_path)
assert payload["content"] == disk_text


@pytest.mark.anyio
Expand Down
Loading