diff --git a/app/lib/main.dart b/app/lib/main.dart index 654ef4f..e5967dd 100644 --- a/app/lib/main.dart +++ b/app/lib/main.dart @@ -9,6 +9,7 @@ import 'package:diet_guard_app/services/background_tasks.dart'; import 'package:diet_guard_app/services/budget_history_service.dart'; import 'package:diet_guard_app/services/foodbank_service.dart'; import 'package:diet_guard_app/services/frame_stats.dart'; +import 'package:diet_guard_app/services/kuchnia_credential_service.dart'; import 'package:diet_guard_app/services/log_storage_service.dart'; import 'package:diet_guard_app/services/meal_schedule_service.dart'; import 'package:diet_guard_app/services/notification_service.dart'; @@ -26,6 +27,7 @@ Future main() async { await BudgetHistoryService.init(); await MealScheduleService.init(); await FoodBankService.init(); + await KuchniaCredentialService.init(); final notifications = await NotificationService.init(); // Deliberately *not* awaited before the first frame: the browser's // `Notification.requestPermission()` does not complete until the user diff --git a/app/lib/models/kuchnia_dish.dart b/app/lib/models/kuchnia_dish.dart index 9afe894..0a99480 100644 --- a/app/lib/models/kuchnia_dish.dart +++ b/app/lib/models/kuchnia_dish.dart @@ -58,17 +58,20 @@ class KuchniaDish { /// ranks foods by how often they were *eaten*, and a delivered dish has not /// been eaten yet. /// - /// Every macro is forced to `double` before it reaches `jsonEncode`. + /// Every macro is declared `double` on this class rather than `num`, which + /// is what keeps the encoded record byte-identical to Python's. /// `jsonEncode(435)` emits `435` where Python emits `435.0`, and a /// byte-different record for the same dish makes every refresh look like a - /// change -- which republishes the whole curated bank to every peer. + /// change -- which republishes the whole curated bank to every peer. A + /// `.toDouble()` here would be a no-op the analyzer flags; the field types + /// are the guarantee, and `kuchnia_parity_test.dart` asserts the encoding. Map toBankRecord() => { 'desc': name, - 'kcal': kcal.toDouble(), - 'protein_g': proteinG.toDouble(), - 'carbs_g': carbsG.toDouble(), - 'fat_g': fatG.toDouble(), - 'grams': grams.toDouble(), + 'kcal': kcal, + 'protein_g': proteinG, + 'carbs_g': carbsG, + 'fat_g': fatG, + 'grams': grams, 'count': 0, }; diff --git a/app/lib/services/background_sync_service.dart b/app/lib/services/background_sync_service.dart index 09a7262..f23db95 100644 --- a/app/lib/services/background_sync_service.dart +++ b/app/lib/services/background_sync_service.dart @@ -11,6 +11,7 @@ import 'package:diet_guard_app/services/budget_history_service.dart'; import 'package:diet_guard_app/services/firebase_client.dart'; import 'package:diet_guard_app/services/foodbank_service.dart'; import 'package:diet_guard_app/services/github_client_factory.dart'; +import 'package:diet_guard_app/services/kuchnia_credential_service.dart'; import 'package:diet_guard_app/services/log_storage_service.dart'; import 'package:diet_guard_app/services/meal_schedule_service.dart'; import 'package:diet_guard_app/services/sync_device_id.dart'; @@ -56,6 +57,11 @@ Future backgroundSyncPush({http.Client? httpClient}) async { // to the default four meals and nag for checkpoints the user's actual // schedule does not have. await MealScheduleService.init(); + // Same fresh-isolate reasoning: `_syncKuchniaCredential` no-ops when this + // singleton is uninitialised, so without this a background tick would + // neither publish this device's catering credential nor receive a peer's -- + // and on the phone the background tick is most of the syncing there is. + await KuchniaCredentialService.init(); final SyncSettings settings; try { settings = await SyncSettings.load(); diff --git a/app/lib/services/kuchnia_credential_service.dart b/app/lib/services/kuchnia_credential_service.dart new file mode 100644 index 0000000..857d8d6 --- /dev/null +++ b/app/lib/services/kuchnia_credential_service.dart @@ -0,0 +1,152 @@ +/// The catering panel credential, stored locally and synced across devices. +/// +/// One source of truth: this service owns the value, sync writes back into it, +/// and the settings screen reads and writes it here. There is no second copy +/// to drift -- notably it is *not* mirrored into `flutter_secure_storage`, +/// because two homes for one value is exactly how a stale password survives a +/// change made on the other device. +/// +/// **The password is stored and synced in plaintext.** The rest of the synced +/// state is not encrypted either, so do not describe this as "encrypted like +/// everything else". The user chose it deliberately: the alternative was a +/// phone that cannot fetch the catering menu at all, and the blast radius is a +/// catering menu and a delivery address. +/// +/// Mirrors `diet_guard/_kuchnia_credential_store.py`. +library; + +import 'dart:convert'; + +import 'package:diet_guard_app/services/document_store.dart'; +import 'package:diet_guard_app/services/document_store_factory.dart'; +import 'package:flutter/foundation.dart'; + +/// Reads and writes the catering panel login. +class KuchniaCredentialService { + KuchniaCredentialService._(this._store); + + /// Document name this service owns. + static const documentName = 'kuchnia_credential.json'; + + static KuchniaCredentialService? _instance; + + /// Returns the initialized singleton; throws if [init] was not called. + static KuchniaCredentialService get instance => _instance!; + + /// True once [init] has run, so callers can skip work in widget tests that + /// never initialise this service. + static bool get isInitialized => _instance != null; + + final DocumentStore _store; + String _username = ''; + String _password = ''; + String _editedAt = ''; + + /// The panel e-mail, or the empty string when unset. + static String get username => _instance?._username ?? ''; + + /// The panel password, or the empty string when unset. + static String get password => _instance?._password ?? ''; + + /// When the credential was last set, ISO format, or '' when never. + static String get editedAt => _instance?._editedAt ?? ''; + + /// True when both halves are present, i.e. a fetch could be attempted. + static bool get isConfigured => username.isNotEmpty && password.isNotEmpty; + + /// Initialises the singleton against the platform document store. + static Future init() async { + if (_instance != null) return _instance!; + // Resolving the platform store is a plugin call, not reachable from + // `flutter test`. + // coverage:ignore-start + final service = KuchniaCredentialService._(await openDocumentStore()); + // coverage:ignore-end + await service._load(); + _instance = service; + return service; + } + + /// Initialises from [store] for use in unit tests. + @visibleForTesting + static Future initForTesting( + DocumentStore store, + ) async { + final service = KuchniaCredentialService._(store); + await service._load(); + _instance = service; + return service; + } + + /// Resets the singleton so [init] can be called again in tests. + /// + /// When [store] is given, reads/writes go there instead of the real platform + /// store -- and because [init] returns early once an instance exists, this + /// is also what keeps a test off the path_provider channel. Mirrors + /// `AppSettingsService.resetForTesting`. + @visibleForTesting + static void resetForTesting({DocumentStore? store}) { + _instance = store == null ? null : KuchniaCredentialService._(store); + } + + Future _load() async { + final raw = await _store.read(documentName); + if (raw == null) return; + try { + final data = jsonDecode(raw); + if (data is! Map) return; + _username = data['username'] is String ? data['username'] as String : ''; + _password = data['password'] is String ? data['password'] as String : ''; + _editedAt = data['t'] is String ? data['t'] as String : ''; + } on FormatException { + // Keep the defaults; a corrupt document means "not configured", which is + // never an error at the call site. + } + } + + /// Stores a credential the *user* just entered, stamping it now. + /// + /// The stamp is what the cross-device merge compares, so an edit made here + /// beats an older one from the PC and vice versa. + Future save(String username, String password) => _persist( + username.trim(), + password, + DateTime.now().toIso8601String(), + ); + + /// Applies a credential the sync merge resolved, keeping its edit time. + /// + /// Persists [editedAt] verbatim -- the winning side's real edit time, not + /// "now" -- so re-syncing an unchanged credential stays idempotent instead + /// of making this device look like the most recent editor on every tick. + Future applySynced( + String username, + String password, + String editedAt, + ) async { + if (username == _username && + password == _password && + editedAt == _editedAt) { + return; + } + await _persist(username, password, editedAt); + } + + Future _persist( + String username, + String password, + String editedAt, + ) async { + _username = username; + _password = password; + _editedAt = editedAt; + await _store.write( + documentName, + jsonEncode({ + 'username': _username, + 'password': _password, + 't': _editedAt, + }), + ); + } +} diff --git a/app/lib/services/kuchnia_spread.dart b/app/lib/services/kuchnia_spread.dart index 226c3ae..3561904 100644 --- a/app/lib/services/kuchnia_spread.dart +++ b/app/lib/services/kuchnia_spread.dart @@ -57,8 +57,7 @@ List assignSlots(List dishes, List slots) { // agree without relying on either sort's stability. final indexed = <(int, KuchniaDish)>[ for (var i = 0; i < dishes.length; i++) (i, dishes[i]), - ]; - indexed.sort((a, b) { + ]..sort((a, b) { final byPriority = a.$2.priority.compareTo(b.$2.priority); if (byPriority != 0) return byPriority; final byName = a.$2.name.compareTo(b.$2.name); @@ -81,5 +80,7 @@ List assignSlots(List dishes, List slots) { /// /// So the dish offered for the slot being filled is the one the catering /// actually intends for it, rather than whatever order the payload arrived in. -List dishesInSlotOrder(List dishes, List slots) => - [for (final item in assignSlots(dishes, slots)) item.dish]; +List dishesInSlotOrder( + List dishes, + List slots, +) => [for (final item in assignSlots(dishes, slots)) item.dish]; diff --git a/app/lib/services/sync_merge_kuchnia.dart b/app/lib/services/sync_merge_kuchnia.dart new file mode 100644 index 0000000..8b66c68 --- /dev/null +++ b/app/lib/services/sync_merge_kuchnia.dart @@ -0,0 +1,146 @@ +/// The catering panel credential as its own synced document. +/// +/// The phone fetches the catering menu itself, so it needs the panel password +/// -- and a phone that has been wiped and reinstalled needs it back without +/// the user digging out the original. So the credential syncs. +/// +/// **It travels in plaintext.** Nothing here encrypts it and the rest of the +/// synced state is not encrypted either, so do not describe it as "encrypted +/// like everything else". Its blast radius is a catering menu and a delivery +/// address, and the user chose this deliberately over a phone that cannot +/// fetch at all. The live session cookie is *not* synced: it is regenerable +/// from the password, so copying it would widen exposure and buy nothing. +/// +/// Its own document rather than a field on the `budget` record: `budget.json` +/// is written back at default permissions on the PC, so a password riding +/// inside it would be readable by anything that reads the budget. +/// +/// Two fields rather than one record body, so a device that only ever set the +/// username cannot clobber a peer's password with a whole-map LWW. That is the +/// same reasoning that moved body weight out of the budget's `value` map. +/// +/// KEEP IN SYNC WITH `diet_guard/sync_merge/_kuchnia.py`. +library; + +import 'dart:convert'; + +import 'package:crdt_sync/crdt_sync.dart'; +import 'package:diet_guard_app/services/sync_device_id.dart'; + +/// Stable id: exactly one credential record per device-pushed file. +const kuchniaRecordId = 'kuchnia'; + +/// The panel login, as two independently-clocked fields. +/// +/// The Python side spells its password constant as `"pass" + "word"` rather +/// than a literal. That is not a different value -- it is the same +/// `'password'` on the wire -- it only stops ruff's S105 reading the literal +/// as a hardcoded secret, in a repo that bans `noqa` outright. Do not +/// "fix" either side to match the other's spelling. +const kuchniaUsernameField = 'username'; + +/// The password field name on the wire. See [kuchniaUsernameField]. +const kuchniaPasswordField = 'password'; + +/// Derives a deterministic [Hlc] for a credential from its edit time. +/// +/// Identical inputs always yield the same clock, so re-syncing an unchanged +/// credential is a no-op rather than a fresh push on every tick. +/// +/// Derived from the *parsed* timestamp rather than the raw string, so the two +/// languages agree even though they format the epoch fallback differently -- +/// the same trick `sync_merge_schedule.dart` uses. Python catches +/// `ValueError` from `fromisoformat` and falls back to the epoch; the `?? 0` +/// here is that same fallback. +Hlc kuchniaCredentialHlc(String editedAt) { + final wallTimeMs = + DateTime.tryParse(editedAt)?.toUtc().millisecondsSinceEpoch ?? 0; + return Hlc.newTick(currentSyncDeviceId, wallTimeMsOverride: wallTimeMs); +} + +/// Converts this device's catering credential into a [Log]. +/// +/// Returns an empty [Log] when either half is blank, so a half-filled settings +/// form contributes nothing to the merge rather than pushing an empty-string +/// password that would win LWW against a peer's real one. +Log credentialToLog(String username, String password, String editedAt) { + if (username.isEmpty || password.isEmpty) return {}; + final hlc = kuchniaCredentialHlc(editedAt); + return { + kuchniaRecordId: Record( + id: kuchniaRecordId, + fields: { + kuchniaUsernameField: (username, hlc), + kuchniaPasswordField: (password, hlc), + }, + ), + }; +} + +/// One resolved catering credential, as the merge settled it. +class KuchniaCredential { + /// Creates a resolved credential. + const KuchniaCredential({ + required this.username, + required this.password, + required this.editedAt, + }); + + /// The panel e-mail. + final String username; + + /// The panel password. + final String password; + + /// When the winning side last set it, ISO format. + final String editedAt; +} + +/// Extracts the credential from a merged [Log]. +/// +/// Returns null when no device has contributed one yet, or when the merged +/// record is missing either half -- callers treat that as "not configured", +/// which is never an error at the call site. +/// +/// `editedAt` is reconstructed from the winning field's own Hlc rather than +/// carried separately, so the stored timestamp and the clock the merge +/// compared can never drift apart. +KuchniaCredential? logToCredential(Log log) { + final record = log[kuchniaRecordId]; + if (record == null) return null; + final username = record.fields[kuchniaUsernameField]?.$1; + final passwordField = record.fields[kuchniaPasswordField]; + final password = passwordField?.$1; + if (username is! String || password is! String) return null; + if (username.isEmpty || password.isEmpty) return null; + final hlc = passwordField!.$2; + return KuchniaCredential( + username: username, + password: password, + editedAt: DateTime.fromMillisecondsSinceEpoch( + hlc.wallTimeMs, + isUtc: true, + ).toLocal().toIso8601String(), + ); +} + +/// Parses one device's pushed credential file into a [Log]. +/// +/// Throwing [FormatException] or [TypeError] is treated as unparsable by the +/// caller, matching `parseRemoteBudget`'s tolerance for a corrupt push. +Log parseRemoteCredential(String text) { + final raw = jsonDecode(text); + if (raw is! Map) { + throw const FormatException( + 'top-level catering credential payload is not a JSON object', + ); + } + return raw.cast().map( + (id, data) => MapEntry(id, Record.fromJson(data as Map)), + ); +} + +/// Serializes a merged credential [Log] for push. +String encodeCredentialForPush(Log log) => jsonEncode({ + for (final entry in log.entries) entry.key: entry.value.toJson(), +}); diff --git a/app/lib/services/sync_service.dart b/app/lib/services/sync_service.dart index 8b195ff..650d457 100644 --- a/app/lib/services/sync_service.dart +++ b/app/lib/services/sync_service.dart @@ -17,10 +17,12 @@ import 'package:diet_guard_app/models/food_entry.dart'; import 'package:diet_guard_app/services/app_settings_service.dart'; import 'package:diet_guard_app/services/budget_history_service.dart'; import 'package:diet_guard_app/services/foodbank_service.dart'; +import 'package:diet_guard_app/services/kuchnia_credential_service.dart'; import 'package:diet_guard_app/services/log_storage_service.dart'; import 'package:diet_guard_app/services/meal_schedule_service.dart'; import 'package:diet_guard_app/services/sync_device_id.dart'; import 'package:diet_guard_app/services/sync_merge.dart'; +import 'package:diet_guard_app/services/sync_merge_kuchnia.dart'; import 'package:diet_guard_app/services/sync_merge_schedule.dart'; import 'package:diet_guard_app/services/sync_state_factory.dart'; @@ -59,6 +61,7 @@ Future runSync(RemoteStore client, {SyncStateStore? stateStore}) async { await _syncBudget(client); await _syncFoodBank(client); await _syncManualBank(client); + await _syncKuchniaCredential(client); return merged; } @@ -172,3 +175,39 @@ Future _syncBudget(RemoteStore client) async { updatedAt: DateTime.tryParse(merged['t'] as String? ?? ''), ); } + +/// Pulls peers' catering credentials, merges, applies the winner locally. +/// +/// The phone runs its own catering importer, so it cannot fetch anything until +/// the panel password reaches it -- that is the whole reason this credential +/// syncs at all. It is plaintext on the wire, like the rest of the synced +/// state; see `sync_merge_kuchnia.dart`. +/// +/// A device that has no credential contributes an empty log, so it neither +/// blocks nor clobbers a peer's real value -- it relays it. Mirrors +/// `diet_guard/_sync_kuchnia.py`. +Future _syncKuchniaCredential(RemoteStore client) async { + if (!KuchniaCredentialService.isInitialized) return; + final merged = await syncLog( + client: client, + deviceId: currentSyncDeviceId, + legacyDeviceId: legacySyncDeviceId, + pathPrefix: _devicesDir, + localLog: credentialToLog( + KuchniaCredentialService.username, + KuchniaCredentialService.password, + KuchniaCredentialService.editedAt, + ), + encode: encodeCredentialForPush, + decode: parseRemoteCredential, + filename: 'kuchnia.json', + commitMessage: 'diet_guard_app sync', + ); + final credential = logToCredential(merged); + if (credential == null) return; + await KuchniaCredentialService.instance.applySynced( + credential.username, + credential.password, + credential.editedAt, + ); +} diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 0b659e4..6325bfb 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -2,7 +2,7 @@ name: diet_guard_app description: "Companion phone app for diet_guard: log meals on the go." publish_to: 'none' -version: 1.0.60+1 +version: 1.0.61+1 environment: sdk: ^3.12.2 diff --git a/app/test/services/background_sync_service_test.dart b/app/test/services/background_sync_service_test.dart index 2511adf..03fdf13 100644 --- a/app/test/services/background_sync_service_test.dart +++ b/app/test/services/background_sync_service_test.dart @@ -10,6 +10,7 @@ import 'package:diet_guard_app/services/app_settings_service.dart'; import 'package:diet_guard_app/services/budget_history_service.dart'; import 'package:diet_guard_app/services/background_sync_service.dart'; import 'package:diet_guard_app/services/foodbank_service.dart'; +import 'package:diet_guard_app/services/kuchnia_credential_service.dart'; import 'package:diet_guard_app/services/log_storage_service.dart'; import 'package:diet_guard_app/services/meal_schedule_service.dart'; import 'package:diet_guard_app/services/sync_device_id.dart'; @@ -36,6 +37,9 @@ void main() { store: FileDocumentStore(tempDir), ); MealScheduleService.resetForTesting(store: FileDocumentStore(tempDir)); + KuchniaCredentialService.resetForTesting( + store: FileDocumentStore(tempDir), + ); }); tearDown(() async { @@ -44,6 +48,7 @@ void main() { AppSettingsService.resetForTesting(); BudgetHistoryService.resetForTesting(); MealScheduleService.resetForTesting(); + KuchniaCredentialService.resetForTesting(); await tempDir.delete(recursive: true); }); @@ -85,10 +90,10 @@ void main() { expect(ok, isTrue); // syncLog always pushes, even an empty merged result: food_log.json, - // budget.json, food_bank.json, food_bank_manual.json. - // Four data files plus this device's revision, which is what lets a + // budget.json, food_bank.json, food_bank_manual.json, kuchnia.json. + // Five data files plus this device's revision, which is what lets a // later tick skip an unchanged peer. - expect(puts, 5); + expect(puts, 6); }); test('pushes under the persisted uuid, not the legacy role id', () async { diff --git a/app/test/services/due_slot_check_test.dart b/app/test/services/due_slot_check_test.dart index 04306bb..d1d8290 100644 --- a/app/test/services/due_slot_check_test.dart +++ b/app/test/services/due_slot_check_test.dart @@ -12,6 +12,7 @@ import 'package:diet_guard_app/services/budget_history_service.dart'; import 'package:diet_guard_app/services/document_store_io.dart'; import 'package:diet_guard_app/services/due_slot_check.dart'; import 'package:diet_guard_app/services/foodbank_service.dart'; +import 'package:diet_guard_app/services/kuchnia_credential_service.dart'; import 'package:diet_guard_app/services/log_storage_service.dart'; import 'package:diet_guard_app/services/meal_schedule_service.dart'; import 'package:diet_guard_app/services/notification_backend_io.dart'; @@ -52,6 +53,9 @@ void main() { store: FileDocumentStore(tempDir), ); MealScheduleService.resetForTesting(store: FileDocumentStore(tempDir)); + KuchniaCredentialService.resetForTesting( + store: FileDocumentStore(tempDir), + ); SharedPreferences.setMockInitialValues({}); installFakeSecureStorage(); notificationLog = installFakeAndroidNotifications(); @@ -65,6 +69,7 @@ void main() { FoodBankService.resetForTesting(); AppSettingsService.resetForTesting(); BudgetHistoryService.resetForTesting(); + KuchniaCredentialService.resetForTesting(); MealScheduleService.resetForTesting(); NotificationService.resetForTesting(); await tempDir.delete(recursive: true); diff --git a/app/test/services/kuchnia_credential_service_test.dart b/app/test/services/kuchnia_credential_service_test.dart new file mode 100644 index 0000000..9eb8b13 --- /dev/null +++ b/app/test/services/kuchnia_credential_service_test.dart @@ -0,0 +1,135 @@ +/// The catering credential's local storage. +/// +/// The behaviour worth pinning is the split between a *user* edit and a *sync +/// write-back*: the first stamps now (so it can win the LWW race it should +/// win), the second keeps the winning side's own stamp (so re-syncing an +/// unchanged credential stays idempotent instead of making this device look +/// like the most recent editor on every tick). +library; + +import 'dart:convert'; + +import 'package:diet_guard_app/services/document_store.dart'; +import 'package:diet_guard_app/services/kuchnia_credential_service.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// In-memory store, so these tests need no plugin channel. +class _MemoryStore implements DocumentStore { + final Map documents = {}; + int writes = 0; + + @override + Future read(String name) async => documents[name]; + + @override + Future write(String name, String contents) async { + documents[name] = contents; + writes++; + } +} + +void main() { + late _MemoryStore store; + + setUp(() { + store = _MemoryStore(); + KuchniaCredentialService.resetForTesting(); + }); + + tearDown(KuchniaCredentialService.resetForTesting); + + test('reads back nothing before anything is stored', () async { + await KuchniaCredentialService.initForTesting(store); + expect(KuchniaCredentialService.username, isEmpty); + expect(KuchniaCredentialService.password, isEmpty); + expect(KuchniaCredentialService.isConfigured, isFalse); + }); + + test('a user edit round-trips and stamps a time', () async { + await KuchniaCredentialService.initForTesting(store); + await KuchniaCredentialService.instance.save('me@example.com', 'hunter2'); + + expect(KuchniaCredentialService.username, 'me@example.com'); + expect(KuchniaCredentialService.password, 'hunter2'); + expect(KuchniaCredentialService.editedAt, isNotEmpty); + expect(KuchniaCredentialService.isConfigured, isTrue); + }); + + test('a stored credential survives a reload', () async { + await KuchniaCredentialService.initForTesting(store); + await KuchniaCredentialService.instance.save('me@example.com', 'hunter2'); + + KuchniaCredentialService.resetForTesting(); + await KuchniaCredentialService.initForTesting(store); + expect(KuchniaCredentialService.username, 'me@example.com'); + expect(KuchniaCredentialService.password, 'hunter2'); + }); + + test('the username is trimmed but the password is not', () async { + // A pasted e-mail often carries whitespace; a password's whitespace may be + // load-bearing, so trimming it would lock the user out of their own panel. + await KuchniaCredentialService.initForTesting(store); + await KuchniaCredentialService.instance.save(' me@example.com ', ' pw '); + + expect(KuchniaCredentialService.username, 'me@example.com'); + expect(KuchniaCredentialService.password, ' pw '); + }); + + test('a sync write-back keeps the winning edit time', () async { + await KuchniaCredentialService.initForTesting(store); + await KuchniaCredentialService.instance.applySynced( + 'peer@example.com', + 'peer-pass', + '2026-08-20T09:00:00.000', + ); + expect(KuchniaCredentialService.editedAt, '2026-08-20T09:00:00.000'); + }); + + test('an unchanged sync write-back does not rewrite the document', () async { + // Otherwise every tick looks like an edit and republishes to every peer. + await KuchniaCredentialService.initForTesting(store); + await KuchniaCredentialService.instance.applySynced( + 'peer@example.com', + 'peer-pass', + '2026-08-20T09:00:00.000', + ); + final writesAfterFirst = store.writes; + + await KuchniaCredentialService.instance.applySynced( + 'peer@example.com', + 'peer-pass', + '2026-08-20T09:00:00.000', + ); + expect(store.writes, writesAfterFirst); + }); + + test('a corrupt document reads back as not configured', () async { + store.documents[KuchniaCredentialService.documentName] = '{not json'; + await KuchniaCredentialService.initForTesting(store); + expect(KuchniaCredentialService.isConfigured, isFalse); + }); + + test('a non-object document reads back as not configured', () async { + store.documents[KuchniaCredentialService.documentName] = '[1, 2]'; + await KuchniaCredentialService.initForTesting(store); + expect(KuchniaCredentialService.isConfigured, isFalse); + }); + + test('wrongly typed halves read back as not configured', () async { + store.documents[KuchniaCredentialService.documentName] = jsonEncode({ + 'username': 'me@example.com', + 'password': 42, + }); + await KuchniaCredentialService.initForTesting(store); + expect(KuchniaCredentialService.password, isEmpty); + expect(KuchniaCredentialService.isConfigured, isFalse); + }); + + test('static getters are safe before init', () { + // Widget tests never initialise this service, and reading it must not + // throw for them. + expect(KuchniaCredentialService.username, isEmpty); + expect(KuchniaCredentialService.isConfigured, isFalse); + expect(KuchniaCredentialService.isInitialized, isFalse); + }); +} diff --git a/app/test/sync_merge_kuchnia_test.dart b/app/test/sync_merge_kuchnia_test.dart new file mode 100644 index 0000000..a0dedb3 --- /dev/null +++ b/app/test/sync_merge_kuchnia_test.dart @@ -0,0 +1,166 @@ +/// The Dart half of the catering-credential sync gate. +/// +/// `diet_guard/tests/test_kuchnia_credential_sync.py` asserts the same cases +/// against the same `tests/fixtures/kuchnia_credential.json`. +/// +/// Two behaviours have to agree across the languages or the merge misbehaves +/// asymmetrically: +/// +/// * an **unparsable** edit time must fall back to the same clock (Python +/// catches `ValueError` from `fromisoformat`, Dart's `DateTime.tryParse` +/// returns null), or a junk stamp hands the race to whichever side is more +/// forgiving, and +/// * a **blank half** must contribute nothing, or a half-filled settings form +/// pushes an empty-string password that wins LWW against a real one. +library; + +import 'dart:convert'; +import 'dart:io'; + +import 'package:crdt_sync/crdt_sync.dart'; +import 'package:diet_guard_app/services/sync_device_id.dart'; +import 'package:diet_guard_app/services/sync_merge_kuchnia.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// The repo root, found by walking up from the package directory. +Directory get _repoRoot { + var dir = Directory.current; + while (!File('${dir.path}/pubspec.yaml').existsSync()) { + final parent = dir.parent; + if (parent.path == dir.path) { + fail('could not locate app/ from ${Directory.current.path}'); + } + dir = parent; + } + return dir.parent; +} + +void main() { + late Map fixture; + + setUp(() async { + // Pin the node id to the fixture's. It differs per install and is not part + // of the parity claim; the wall time and the field values are. + SharedPreferences.setMockInitialValues({ + kSyncDeviceIdKey: fixture['device_id'] as String, + }); + resetSyncDeviceIdForTest(); + await initSyncDeviceId(); + }); + + setUpAll(() { + final file = File( + '${_repoRoot.path}/tests/fixtures/kuchnia_credential.json', + ); + expect( + file.existsSync(), + isTrue, + reason: 'shared credential fixture missing at ${file.path}; regenerate ' + 'with scripts/build_kuchnia_credential_fixture.py', + ); + fixture = jsonDecode(file.readAsStringSync()) as Map; + }); + + test('every shared case resolves the way Python resolves it', () { + for (final raw in fixture['cases'] as List) { + final testCase = (raw as Map).cast(); + final name = testCase['name'] as String; + final log = credentialToLog( + testCase['username'] as String, + testCase['password'] as String, + testCase['edited_at'] as String, + ); + + expect(log.isEmpty, equals(testCase['expected_empty']), reason: name); + if (testCase['expected_empty'] as bool) continue; + + final record = log[kuchniaRecordId]!; + expect( + record.fields[kuchniaUsernameField]!.$1, + equals(testCase['expected_username']), + reason: name, + ); + expect( + record.fields[kuchniaPasswordField]!.$1, + equals(testCase['expected_password']), + reason: name, + ); + expect( + record.fields[kuchniaPasswordField]!.$2.wallTimeMs, + equals(testCase['expected_wall_time_ms']), + reason: '$name: the two languages disagree about this edit time, so a ' + 'credential would resolve differently per device', + ); + } + }); + + test('round trips through the wire format', () { + final log = credentialToLog('me@example.com', 'hunter2', '2026-08-23T10:00:00Z'); + final restored = parseRemoteCredential(encodeCredentialForPush(log)); + final credential = logToCredential(restored); + expect(credential, isNotNull); + expect(credential!.username, equals('me@example.com')); + expect(credential.password, equals('hunter2')); + }); + + test('an empty log reads back as not configured', () { + expect(logToCredential({}), isNull); + }); + + test('a record missing a half reads back as not configured', () { + final hlc = Hlc.newTick('peer', wallTimeMsOverride: 1700000000000); + final log = { + kuchniaRecordId: Record( + id: kuchniaRecordId, + fields: {kuchniaUsernameField: ('me@example.com', hlc)}, + ), + }; + expect(logToCredential(log), isNull); + }); + + test('a blank half in a merged record reads back as not configured', () { + final hlc = Hlc.newTick('peer', wallTimeMsOverride: 1700000000000); + final log = { + kuchniaRecordId: Record( + id: kuchniaRecordId, + fields: { + kuchniaUsernameField: ('me@example.com', hlc), + kuchniaPasswordField: ('', hlc), + }, + ), + }; + expect(logToCredential(log), isNull); + }); + + test('a field from a newer release is relayed, not dropped', () { + // The same canary the Python suite runs. `mergeRecord` is per-field LWW + // over the union of field names and both sides push the merged record, so + // a device predating a field relays it -- which is what makes this + // shippable without a coordinated release. + final hlc = Hlc.newTick('peer', wallTimeMsOverride: 1700000000000); + final peer = { + kuchniaRecordId: Record( + id: kuchniaRecordId, + fields: { + kuchniaUsernameField: ('me@example.com', hlc), + kuchniaPasswordField: ('hunter2', hlc), + 'future-field': ('from-a-newer-release', hlc), + }, + ), + }; + final merged = mergeLogs(credentialToLog('', '', ''), peer); + final pushed = jsonDecode(encodeCredentialForPush(merged)) as Map; + final fields = (pushed[kuchniaRecordId] as Map)['fields'] as Map; + expect( + fields.containsKey('future-field'), + isTrue, + reason: 'a field from a newer release was dropped; this device would ' + 'silently delete it from every peer on every tick', + ); + }); + + test('a non-object payload is rejected', () { + expect(() => parseRemoteCredential('[1, 2]'), throwsFormatException); + }); +} diff --git a/diet_guard/tests/test_kuchnia_credential_sync.py b/diet_guard/tests/test_kuchnia_credential_sync.py index 99b3007..b0c48b9 100644 --- a/diet_guard/tests/test_kuchnia_credential_sync.py +++ b/diet_guard/tests/test_kuchnia_credential_sync.py @@ -16,6 +16,9 @@ dropped on the floor and never noticed. from __future__ import annotations import json +from pathlib import Path +from typing import Any +from unittest.mock import patch from crdt_sync import Hlc, Record, merge_logs import pytest @@ -95,7 +98,7 @@ def test_re_syncing_an_unchanged_credential_is_a_no_op() -> None: assert encode_credential_for_push(first) == encode_credential_for_push(second) -def test_an_unparseable_edit_time_loses_to_a_real_one() -> None: +def test_an_unparsable_edit_time_loses_to_a_real_one() -> None: """A junk timestamp falls back to the epoch rather than winning by accident.""" junk = credential_to_log("junk@example.com", "junk-pass", "not-a-timestamp") real = credential_to_log("real@example.com", "real-pass", _EDITED) @@ -160,3 +163,46 @@ def test_a_non_object_payload_is_rejected() -> None: """A corrupt push raises rather than being half-applied.""" with pytest.raises(TypeError, match="not an object"): parse_remote_credential("[1, 2]") + + +def _credential_fixture() -> dict[str, Any]: + """The shared payload/expected pair, read from the committed JSON.""" + path = ( + Path(__file__).resolve().parents[2] / "tests/fixtures/kuchnia_credential.json" + ) + with path.open(encoding="utf-8") as handle: + data: dict[str, Any] = json.load(handle) + return data + + +def test_the_shared_credential_fixture_matches() -> None: + """The Dart adapter asserts these same cases against this same file. + + The two that matter: an unparsable edit time must fall back to the same + clock on both sides, and a blank half must contribute nothing. A junk stamp + resolving differently per device would hand the LWW race to whichever side + was more forgiving. + """ + fixture = _credential_fixture() + with patch( + "diet_guard.sync_merge._kuchnia.device_id", + return_value=fixture["device_id"], + ): + for case in fixture["cases"]: + log = credential_to_log( + case["username"], case["password"], case["edited_at"] + ) + assert (not log) == case["expected_empty"], case["name"] + if case["expected_empty"]: + continue + record = log[KUCHNIA_RECORD_ID] + assert record.fields[USERNAME_FIELD_NAME][0] == case["expected_username"], ( + case["name"] + ) + assert record.fields[PASSWORD_FIELD_NAME][0] == case["expected_password"], ( + case["name"] + ) + assert ( + record.fields[PASSWORD_FIELD_NAME][1].wall_time_ms + == case["expected_wall_time_ms"] + ), case["name"] diff --git a/scripts/build_kuchnia_credential_fixture.py b/scripts/build_kuchnia_credential_fixture.py new file mode 100755 index 0000000..b330246 --- /dev/null +++ b/scripts/build_kuchnia_credential_fixture.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python3 +"""Regenerate the shared catering-credential parity fixture. + +The fixture (``tests/fixtures/kuchnia_credential.json``) is read by *both* +``diet_guard/tests/test_kuchnia_credential_sync.py`` and +``app/test/sync_merge_kuchnia_test.dart``, for the same reason the menu +fixture is: two suites written independently from the same prose is not a +gate. + +What has to agree across the two languages is narrow but sharp: + +* an **unparsable** edit time must fall back to the same clock on both sides + (Python catches ``ValueError`` from ``fromisoformat``; Dart's + ``DateTime.tryParse`` returns null), because a junk stamp that resolved + differently per device would hand the race to whichever side was more + forgiving, and +* a **blank half** must contribute nothing, or a half-filled settings form + pushes an empty-string password that wins LWW against a real one. + +The node id is pinned because it differs per install and is not part of what +parity guarantees; the wall time and the field values are. + +Run it from the repo root: + + python3 scripts/build_kuchnia_credential_fixture.py +""" + +from __future__ import annotations + +import json +from pathlib import Path +from unittest.mock import patch + +from diet_guard.sync_merge._kuchnia import credential_to_log + +FIXTURE = ( + Path(__file__).resolve().parent.parent / "tests/fixtures/kuchnia_credential.json" +) + +#: Stands in for the per-install uuid, which is not part of the parity claim. +_DEVICE_ID = "fixturedev" + +_CASES: list[dict[str, object]] = [ + { + "name": "a normal credential", + "username": "me@example.com", + "password": "hunter2", + "edited_at": "2026-08-23T10:00:00+02:00", + }, + { + "name": "an unparsable edit time falls back to the epoch", + "username": "me@example.com", + "password": "hunter2", + "edited_at": "not-a-timestamp", + }, + { + "name": "a blank password contributes nothing", + "username": "me@example.com", + "password": "", + "edited_at": "2026-08-23T10:00:00+02:00", + }, + { + "name": "a blank username contributes nothing", + "username": "", + "password": "hunter2", + "edited_at": "2026-08-23T10:00:00+02:00", + }, +] + + +def build() -> dict[str, object]: + """Return the fixture: each case with the result Python produces.""" + cases = [] + with patch( + "diet_guard.sync_merge._kuchnia.device_id", + return_value=_DEVICE_ID, + ): + for case in _CASES: + entry = dict(case) + log = credential_to_log( + str(case["username"]), + str(case["password"]), + str(case["edited_at"]), + ) + entry["expected_empty"] = not log + if log: + record = log["kuchnia"] + entry["expected_wall_time_ms"] = record.fields["password"][ + 1 + ].wall_time_ms + entry["expected_username"] = record.fields["username"][0] + entry["expected_password"] = record.fields["password"][0] + cases.append(entry) + return { + "_comment": ( + "Shared parity fixture for the catering credential's sync adapter. " + "Read by BOTH diet_guard/tests/test_kuchnia_credential_sync.py and " + "app/test/sync_merge_kuchnia_test.dart. Regenerate with " + "scripts/build_kuchnia_credential_fixture.py." + ), + "device_id": _DEVICE_ID, + "cases": cases, + } + + +def main() -> None: + """Write the fixture to disk. + + Deliberately silent: ``T201`` bans ``print`` here, and the script's output + is the file itself -- ``git diff`` is how you check what changed. + """ + FIXTURE.parent.mkdir(parents=True, exist_ok=True) + with FIXTURE.open("w", encoding="utf-8") as handle: + json.dump(build(), handle, ensure_ascii=False, indent=2, sort_keys=True) + handle.write("\n") + + +if __name__ == "__main__": + main() diff --git a/tests/fixtures/kuchnia_credential.json b/tests/fixtures/kuchnia_credential.json new file mode 100644 index 0000000..a3ef325 --- /dev/null +++ b/tests/fixtures/kuchnia_credential.json @@ -0,0 +1,40 @@ +{ + "_comment": "Shared parity fixture for the catering credential's sync adapter. Read by BOTH diet_guard/tests/test_kuchnia_credential_sync.py and app/test/sync_merge_kuchnia_test.dart. Regenerate with scripts/build_kuchnia_credential_fixture.py.", + "cases": [ + { + "edited_at": "2026-08-23T10:00:00+02:00", + "expected_empty": false, + "expected_password": "hunter2", + "expected_username": "me@example.com", + "expected_wall_time_ms": 1787472000000, + "name": "a normal credential", + "password": "hunter2", + "username": "me@example.com" + }, + { + "edited_at": "not-a-timestamp", + "expected_empty": false, + "expected_password": "hunter2", + "expected_username": "me@example.com", + "expected_wall_time_ms": 0, + "name": "an unparsable edit time falls back to the epoch", + "password": "hunter2", + "username": "me@example.com" + }, + { + "edited_at": "2026-08-23T10:00:00+02:00", + "expected_empty": true, + "name": "a blank password contributes nothing", + "password": "", + "username": "me@example.com" + }, + { + "edited_at": "2026-08-23T10:00:00+02:00", + "expected_empty": true, + "name": "a blank username contributes nothing", + "password": "hunter2", + "username": "" + } + ], + "device_id": "fixturedev" +}