Skip to content

Commit 2ad0f65

Browse files
committed
Address Codex review on #18: all four findings were correct
**Entry title leaked the username (P1).** async_step_user names the entry after the account, so the title *is* the username, and async_redact_data only matches keys called username or password. "title" is now redacted too. My own test claimed to cover this and did not: the ConfigEntry double defaulted to title="alice" while the test looked for "alice@example.com", so the two never met. The double now models what the config flow actually does. **Free-form notes could reach a public issue (P1).** The sample bottle denied three location fields and published everything else, including prose someone wrote — tasting and cellar notes can say anything about anyone. A denylist also has to be right about every column CellarTracker adds in future. The sample is now an allowlist of the fields the integration itself reads or derives, which is what debugging it needs. Nothing is lost: "columns" still lists every column name, and that is the signal schema drift actually needs. **Backoff could shorten a long schedule (P2).** The options schema sets a minimum interval and no maximum, so a daily poll is configurable — and capping at MAX_BACKOFF turned it into a six-hourly one while being rate limited. Four times the requests, and the exact opposite of backing off, in the method whose docstring promises never to poll sooner than configured. The cap now bounds what the server can ask for, and the configured interval is applied as the floor on top of it. **The sync timestamp never reached its sensor (P2).** always_update=False suppresses listener notification when a payload compares equal to the previous one, and a cellar's inventory is identical between most polls. The timestamp was held outside the payload, so it took part in no comparison and the sensor would have shown the last time a *bottle* changed while claiming to show the last successful sync — the feature not working as described. It is carried inside the payload now. That also means no two payloads ever compare equal, so always_update=False could never suppress anything and has been dropped rather than left as a flag that reads like it does something. 9 tests added, 329 passing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LLPEGFSy3fLEuXNUPAPWR4
1 parent bd6e570 commit 2ad0f65

6 files changed

Lines changed: 185 additions & 15 deletions

File tree

custom_components/cellar_tracker/cellar_data.py

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -260,7 +260,10 @@ def __init__(self, hass: HomeAssistant, entry: ConfigEntry):
260260
# entry, so unloading cancels a poll still in flight.
261261
config_entry=entry,
262262
update_interval=scan_interval,
263-
always_update=False,
263+
# Every payload now carries the time of the poll that produced it,
264+
# so no two ever compare equal and this could never suppress an
265+
# update. Left at the default rather than kept as a flag that
266+
# reads like it does something.
264267
)
265268

266269
# Consecutive polls that reported an empty cellar after it held stock.
@@ -332,7 +335,13 @@ def _backoff_for(self, retry_after: int | None) -> timedelta:
332335
"""
333336
configured = int(self._scan_interval.total_seconds())
334337
seconds = retry_after if retry_after is not None else configured * 2
335-
return timedelta(seconds=min(max(seconds, configured), MAX_BACKOFF))
338+
339+
# Cap what the *server* can ask for, then apply the configured interval
340+
# as the floor. Doing it the other way round let the cap undercut a
341+
# schedule longer than six hours - the options schema sets a minimum
342+
# and no maximum, so a daily poll became six-hourly while being rate
343+
# limited, which is the opposite of backing off.
344+
return timedelta(seconds=max(min(seconds, MAX_BACKOFF), configured))
336345

337346
def _restore_interval(self) -> None:
338347
"""Undo a backoff once CellarTracker is answering again."""
@@ -508,7 +517,14 @@ async def _async_update_data(self) -> dict:
508517
# than leaving the coordinator to log a traceback.
509518
raise UpdateFailed(f"Malformed CellarTracker export: {err}") from err
510519

520+
# Carried inside the payload, not just alongside it. The coordinator
521+
# compares payloads to decide whether to notify listeners, and a
522+
# cellar's inventory is identical between most polls - so a timestamp
523+
# held outside would never reach the sensor, and "last synchronised"
524+
# would quietly come to mean "last time a bottle changed".
511525
self._last_success = dt_util.utcnow()
526+
data["last_success"] = self._last_success
527+
512528
self._restore_interval()
513529
return data
514530

custom_components/cellar_tracker/diagnostics.py

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,10 @@
33
A diagnostics download is routinely pasted into a public issue, so the question
44
is not only what would help us but what the user would regret publishing.
55
6-
Redacted: the password, and the username too - it is half of a credential pair
7-
and names a real CellarTracker account. Per bottle, the Barcode, Location and
8-
Bin, which describe someone's home and are no use in diagnosing a parsing bug.
6+
Redacted: the password, the username, and the entry title - which is the
7+
username, because that is what the config flow names the entry after. Per
8+
bottle, everything outside a small allowlist, so a column nobody reviewed
9+
cannot walk into a public issue.
910
1011
Kept: the column list, which is the signal that matters. Every "no 'iWine'
1112
column" report comes down to CellarTracker having changed its export, and the
@@ -21,9 +22,29 @@
2122
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
2223
from homeassistant.core import HomeAssistant
2324

24-
TO_REDACT = {CONF_PASSWORD, CONF_USERNAME}
25+
# "title" as well as the credential keys: async_step_user names the entry
26+
# after the account, so the title *is* the username for every entry this
27+
# integration creates. Redacting only the username field would have published
28+
# it one line further down.
29+
TO_REDACT = {CONF_PASSWORD, CONF_USERNAME, "title"}
2530

26-
BOTTLE_REDACT = {"Barcode", "Location", "Bin"}
31+
# The sample bottle is an allowlist rather than a denylist. A denylist has to
32+
# be right about every column CellarTracker has today and every one it adds
33+
# later, and the export already carries free-form prose - tasting and cellar
34+
# notes - that can say anything at all about anyone. These are the fields the
35+
# integration itself reads or derives, which is what debugging it needs.
36+
#
37+
# Nothing is lost by omitting the rest: "columns" below still lists every
38+
# column name, which is the signal schema drift actually needs.
39+
SAMPLE_FIELDS = (
40+
"iWine",
41+
"Wine",
42+
"Vintage",
43+
"Valuation",
44+
"BeginConsume",
45+
"EndConsume",
46+
"unique_bottle_id",
47+
)
2748

2849

2950
async def async_get_config_entry_diagnostics(
@@ -51,6 +72,8 @@ async def async_get_config_entry_diagnostics(
5172
# One row is enough to show how the export is shaped. Shipping the whole
5273
# cellar would be both useless and a privacy problem.
5374
"sample_bottle": (
54-
async_redact_data(bottles[0], BOTTLE_REDACT) if bottles else None
75+
{field: bottles[0][field] for field in SAMPLE_FIELDS if field in bottles[0]}
76+
if bottles
77+
else None
5578
),
5679
}

tests/test_diagnostics.py

Lines changed: 50 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,10 @@
2121
import asyncio
2222
import json
2323

24-
from cellar_tracker.diagnostics import async_get_config_entry_diagnostics
24+
from cellar_tracker.diagnostics import (
25+
SAMPLE_FIELDS,
26+
async_get_config_entry_diagnostics,
27+
)
2528
from conftest import ConfigEntry, ViewHass
2629

2730
PASSWORD = "hunter2-do-not-leak"
@@ -35,6 +38,9 @@
3538
"Barcode": "BC-77-4412",
3639
"Location": "Cellar under the stairs",
3740
"Bin": "A4",
41+
"BottleNote": "bought for Anna's 40th, keep for her",
42+
"CNotes": "cellar note naming the neighbour who has the spare key",
43+
"PNotes": "private note",
3844
"unique_bottle_id": "abcdef0123456789",
3945
}
4046

@@ -51,6 +57,9 @@ def __init__(self, data=None, last_update_success=True):
5157
def diagnostics(coordinator) -> dict:
5258
entry = ConfigEntry(
5359
entry_id="a",
60+
# async_step_user sets the title to the username. The double defaulted
61+
# to something else, which is why the leak below went unnoticed.
62+
title=USERNAME,
5463
data={"username": USERNAME, "password": PASSWORD, "currency": "SEK"},
5564
options={"scan_interval": 21600},
5665
)
@@ -80,11 +89,12 @@ def test_the_bottle_sample_hides_where_the_wine_lives():
8089
report = diagnostics(_Coordinator(stocked()))
8190
sample = report["sample_bottle"]
8291

83-
# Asserted field by field rather than by searching the rendered report:
84-
# a substring search gives false positives against unrelated values, and
85-
# a short Bin like "A4" appears inside plenty of innocent text.
92+
# Absent rather than redacted: the sample is an allowlist, so these were
93+
# never copied in. Asserted field by field because a substring search over
94+
# the rendered report gives false positives - a short Bin like "A4"
95+
# appears inside plenty of innocent text.
8696
for field in ("Barcode", "Location", "Bin"):
87-
assert sample[field] == "**REDACTED**", f"{field} was not redacted"
97+
assert field not in sample, f"{field} must not reach the report"
8898

8999
assert "Cellar under the stairs" not in rendered(report)
90100

@@ -124,3 +134,38 @@ def test_a_coordinator_that_never_refreshed_produces_a_report():
124134
report = diagnostics(_Coordinator(None, last_update_success=False))
125135
assert report["totals"]["total_bottles"] is None
126136
assert report["sample_bottle"] is None
137+
138+
139+
# --------------------------------------------------------------------------
140+
# Reported by Codex on #18
141+
# --------------------------------------------------------------------------
142+
def test_the_entry_title_does_not_leak_the_username():
143+
"""The title *is* the username for every entry this integration creates."""
144+
report = diagnostics(_Coordinator(stocked()))
145+
assert USERNAME not in rendered(report)
146+
assert report["entry"]["title"] == "**REDACTED**"
147+
148+
149+
def test_free_form_notes_never_reach_the_report():
150+
"""Tasting and cellar notes are prose someone wrote; they can say anything."""
151+
sample = diagnostics(_Coordinator(stocked()))["sample_bottle"]
152+
153+
for field in ("BottleNote", "CNotes", "PNotes"):
154+
assert field not in sample, f"{field} is free-form and must not be published"
155+
156+
157+
def test_the_sample_is_an_allowlist_not_a_denylist():
158+
"""A denylist ships every column CellarTracker adds in future, unreviewed."""
159+
sample = diagnostics(_Coordinator(stocked()))["sample_bottle"]
160+
assert set(sample) <= set(SAMPLE_FIELDS)
161+
162+
163+
def test_an_unknown_column_is_omitted_rather_than_published():
164+
bottle = {**BOTTLE, "SomeColumnAddedNextYear": "who knows what this holds"}
165+
report = diagnostics(
166+
_Coordinator({"total_bottles": 1, "total_value": 0.0, "bottles": [bottle]})
167+
)
168+
169+
assert "SomeColumnAddedNextYear" not in report["sample_bottle"]
170+
# ...but its existence is still visible, which is what schema drift needs.
171+
assert "SomeColumnAddedNextYear" in report["columns"]

tests/test_inventory_validation.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,15 @@
2929
SINGLE_LINE_ERROR_PAGE = "<html><title>503 Service Unavailable</title></html>"
3030

3131
STOCKED = {"total_bottles": 412, "total_value": 9000.0, "bottles": []}
32+
def inventory_of(data: dict) -> dict:
33+
"""The payload without the poll timestamp.
34+
35+
Every successful poll carries ``last_success`` so the coordinator's payload
36+
comparison always differs; these tests are about the inventory it describes.
37+
"""
38+
return {key: value for key, value in data.items() if key != "last_success"}
39+
40+
3241
EMPTY = {
3342
"total_bottles": 0,
3443
"total_value": 0.0,
@@ -183,7 +192,7 @@ def test_last_bottle_drunk_recovers_within_two_polls():
183192

184193
with pytest.raises(UpdateFailed):
185194
asyncio.run(coordinator._async_update_data())
186-
assert asyncio.run(coordinator._async_update_data()) == EMPTY
195+
assert inventory_of(asyncio.run(coordinator._async_update_data())) == EMPTY
187196

188197

189198
# --------------------------------------------------------------------------
@@ -198,4 +207,4 @@ def test_coordinator_passes_previous_data_through():
198207

199208
def test_coordinator_first_poll_of_empty_account_succeeds():
200209
coordinator = build_coordinator(returns=[], previous=None)
201-
assert asyncio.run(coordinator._async_update_data()) == EMPTY
210+
assert inventory_of(asyncio.run(coordinator._async_update_data())) == EMPTY

tests/test_last_sync_sensor.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,3 +97,40 @@ def test_the_coordinator_records_when_it_last_succeeded():
9797

9898
assert coordinator.last_success is not None
9999
assert coordinator.last_success.tzinfo is not None
100+
101+
102+
# --------------------------------------------------------------------------
103+
# Reported by Codex on #18
104+
# --------------------------------------------------------------------------
105+
def test_the_timestamp_is_part_of_the_coordinator_payload():
106+
"""Otherwise always_update=False suppresses the update that carries it.
107+
108+
A cellar's inventory is identical between most polls, so the coordinator
109+
compares the new payload equal to the old and notifies no listeners. A
110+
timestamp held outside that payload therefore never reaches the sensor,
111+
and "last synchronised" would silently mean "last time a bottle changed".
112+
"""
113+
from cellar_tracker.cellar_data import WineCellarData
114+
from conftest import FakeHass, FakeSession
115+
116+
export = "iWine\tWine\tValuation\n1\tBarolo\t45.50"
117+
hass = FakeHass()
118+
hass.session = FakeSession(text=export)
119+
coordinator = WineCellarData(hass, ConfigEntry(data={"username": "a", "password": "b"}))
120+
121+
first = asyncio.run(coordinator._async_update_data())
122+
coordinator.data = first
123+
second = asyncio.run(coordinator._async_update_data())
124+
125+
assert "last_success" in first
126+
assert first["bottles"] == second["bottles"], "the fixture must be unchanged"
127+
assert first != second, (
128+
"two polls of an unchanged cellar produced equal payloads, so the "
129+
"coordinator would notify no listeners and the timestamp would stall"
130+
)
131+
132+
133+
def test_the_sensor_reads_the_timestamp_the_payload_carries():
134+
stamp = datetime(2026, 9, 2, 8, 30, tzinfo=UTC)
135+
sensor = build(_Coordinator(last_success=stamp))
136+
assert sensor.native_value == stamp

tests/test_rate_limit.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,3 +116,43 @@ def test_throttling_is_logged_without_alarm(caplog):
116116

117117
assert "429" in caplog.text
118118
assert not [r for r in caplog.records if r.levelname in ("WARNING", "ERROR")]
119+
120+
121+
# --------------------------------------------------------------------------
122+
# Reported by Codex on #18: the cap must never undercut the configured interval
123+
# --------------------------------------------------------------------------
124+
DAILY = 86400
125+
126+
127+
def daily(**session_kwargs) -> WineCellarData:
128+
hass = FakeHass()
129+
hass.session = FakeSession(**session_kwargs)
130+
entry = ConfigEntry(
131+
data={"username": "alice", "password": "s3cret", "scan_interval": DAILY}
132+
)
133+
return WineCellarData(hass, entry)
134+
135+
136+
def test_a_daily_schedule_is_not_shortened_by_the_cap():
137+
"""The options schema sets a floor, not a ceiling, so this is reachable.
138+
139+
Capping at MAX_BACKOFF turned a 24-hour schedule into a six-hourly one
140+
*while being rate limited* - four times the requests, and the exact
141+
opposite of backing off.
142+
"""
143+
coordinator = daily(raise_for_status=throttled("1800"))
144+
refresh(coordinator)
145+
assert coordinator.update_interval >= timedelta(seconds=DAILY)
146+
147+
148+
def test_a_daily_schedule_with_no_retry_after_is_not_shortened():
149+
coordinator = daily(raise_for_status=throttled(None))
150+
refresh(coordinator)
151+
assert coordinator.update_interval >= timedelta(seconds=DAILY)
152+
153+
154+
def test_a_server_still_cannot_extend_a_daily_schedule_without_bound():
155+
"""The cap still applies on top of the configured interval, not under it."""
156+
coordinator = daily(raise_for_status=throttled("604800"))
157+
refresh(coordinator)
158+
assert coordinator.update_interval == timedelta(seconds=DAILY)

0 commit comments

Comments
 (0)