feat(train): the person's workouts, sealed and synced (ADR 0030, D14)
WorkoutRepository keeps a workout with its series, sealed with the person's key: a workout for its row, a series chunk for its row and its workout, so the instance can neither read them nor move a chunk to another workout. A readable or changed row shows locked, never as the person's; a device without the account's keys saves nothing. On the device's database a workout and its series are written in one transaction, and a deleted one is blanked before it goes, so the sync engine's tombstones hold nothing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
61fc5c5f7c
commit
d1daec9c0b
2 changed files with 801 additions and 0 deletions
|
|
@ -0,0 +1,140 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
import 'package:halkyon_train_client/halkyon_train_client.dart' as protocol;
|
||||
import 'package:halkyon_train_flutter/src/core/privacy/sync_content_key_gateway.dart';
|
||||
import 'package:halkyon_train_flutter/src/core/privacy/train_keys.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/workout/data/sync_workout_gateway.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/workout/data/workout_repository.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/workout/model/series_chunk.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/workout/model/stored_workout.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
|
||||
import 'package:serverpod_offline_sync_client/offline_sync.dart';
|
||||
|
||||
// The workouts on the device's own database, as the sync engine keeps them,
|
||||
// with libsodium as its build hook compiles it.
|
||||
|
||||
final _person = protocol.UuidValue.withValidation(
|
||||
'01a16000-0000-7000-8000-000000000001',
|
||||
);
|
||||
|
||||
void main() {
|
||||
final crypto = SodiumAccountCrypto();
|
||||
late UnlockedAccountKeys alice;
|
||||
late Directory folder;
|
||||
late OfflineSyncDatabaseSession db;
|
||||
late SyncService sync;
|
||||
late TrainKeys keys;
|
||||
late WorkoutRepository workouts;
|
||||
|
||||
final start = DateTime.utc(2026, 10, 28, 7, 30);
|
||||
final ride = WorkoutContent(
|
||||
activity: Activity.cycling,
|
||||
startedAt: start,
|
||||
endedAt: start.add(const Duration(minutes: 30)),
|
||||
);
|
||||
final positions = chunksOf(
|
||||
SeriesKind.position,
|
||||
start: start,
|
||||
times: [for (var s = 0; s < 30 * 60; s++) s * 1000],
|
||||
columns: {
|
||||
'lat': [for (var s = 0; s < 30 * 60; s++) 525200000 + s * 40],
|
||||
'lon': [for (var s = 0; s < 30 * 60; s++) 134050000 + s * 25],
|
||||
},
|
||||
);
|
||||
|
||||
setUpAll(() async {
|
||||
alice = (await crypto.newAccount('alice password')).keys;
|
||||
});
|
||||
|
||||
setUp(() async {
|
||||
folder = await Directory.systemTemp.createTemp('workouts');
|
||||
db = await protocol.Client(
|
||||
'http://127.0.0.1:9/',
|
||||
).createSyncSession('${folder.path}/db.sqlite', persistentUserId: _person);
|
||||
sync = SyncService(
|
||||
({required onMerge}) async {},
|
||||
debounce: const Duration(days: 1),
|
||||
);
|
||||
keys = TrainKeys(
|
||||
SyncContentKeyGateway(db, sync),
|
||||
SodiumContentCipher(),
|
||||
() async => alice,
|
||||
);
|
||||
await keys.refresh();
|
||||
workouts = WorkoutRepository(SyncWorkoutGateway(db, sync), keys);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await workouts.dispose();
|
||||
keys.dispose();
|
||||
await sync.dispose();
|
||||
await db.close();
|
||||
await folder.delete(recursive: true);
|
||||
});
|
||||
|
||||
test('a workout and its series are kept, and read back opened', () async {
|
||||
final id = await workouts.save(ride, positions);
|
||||
|
||||
expect(await workouts.all(), [StoredWorkout(id, ride)]);
|
||||
expect(await workouts.series(id), positions);
|
||||
expect(positions, hasLength(3));
|
||||
});
|
||||
|
||||
test("its key is kept too, so the person's other devices open it", () async {
|
||||
final id = await workouts.save(ride, positions);
|
||||
|
||||
final laptop = TrainKeys(
|
||||
SyncContentKeyGateway(db, sync),
|
||||
SodiumContentCipher(),
|
||||
() async => alice,
|
||||
);
|
||||
addTearDown(laptop.dispose);
|
||||
await laptop.refresh();
|
||||
final elsewhere = WorkoutRepository(SyncWorkoutGateway(db, sync), laptop);
|
||||
addTearDown(elsewhere.dispose);
|
||||
|
||||
expect(await elsewhere.all(), [StoredWorkout(id, ride)]);
|
||||
});
|
||||
|
||||
test('an edit changes the workout alone', () async {
|
||||
final id = await workouts.save(ride, positions);
|
||||
final named = WorkoutContent(
|
||||
activity: ride.activity,
|
||||
startedAt: ride.startedAt,
|
||||
endedAt: ride.endedAt,
|
||||
name: 'To the lake',
|
||||
);
|
||||
|
||||
await workouts.update(id, named);
|
||||
|
||||
expect(await workouts.all(), [StoredWorkout(id, named)]);
|
||||
expect(await workouts.series(id), positions);
|
||||
});
|
||||
|
||||
test('deleting a workout deletes it with its series, every payload '
|
||||
'blanked first', () async {
|
||||
final id = await workouts.save(ride, positions);
|
||||
final kept = await workouts.save(ride, positions.take(1).toList());
|
||||
|
||||
await workouts.delete(id);
|
||||
|
||||
expect(await workouts.all(), [StoredWorkout(kept, ride)]);
|
||||
expect(await workouts.series(id), isEmpty);
|
||||
// The database keeps deleted rows as the sync engine's tombstones, as
|
||||
// the instance does: past the engine, they hold nothing but blanks.
|
||||
for (final (table, live) in [('workout', 1), ('workout_series', 1)]) {
|
||||
final rows = await db.db.unsafeQuery(
|
||||
'SELECT length("payload"), "keyId", "nonce" FROM "$table"',
|
||||
);
|
||||
final blank = [
|
||||
for (final row in rows)
|
||||
if (row[0] == 0) row,
|
||||
];
|
||||
expect(rows.length - blank.length, live, reason: table);
|
||||
expect(blank, hasLength(table == 'workout' ? 1 : 3), reason: table);
|
||||
expect(blank.every((row) => row[1] == null && row[2] == null), isTrue);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,196 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
import 'package:halkyon_train_flutter/src/core/privacy/train_keys.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/workout/data/workout_repository.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/workout/model/series_chunk.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/workout/model/stored_workout.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
|
||||
|
||||
import '../../support/memory_content_key_gateway.dart';
|
||||
import '../../support/memory_workout_gateway.dart';
|
||||
|
||||
/// The person's workouts, sealed on the way to the instance and opened on
|
||||
/// the way back (ADR 0030, D7 and D14), with libsodium as its build hook
|
||||
/// compiles it.
|
||||
void main() {
|
||||
final crypto = SodiumAccountCrypto();
|
||||
late UnlockedAccountKeys alice;
|
||||
late MemoryContentKeyGateway keyRows;
|
||||
late MemoryWorkoutGateway rows;
|
||||
final opened = <TrainKeys>[];
|
||||
|
||||
final start = DateTime.utc(2026, 10, 28, 7, 30);
|
||||
final run = WorkoutContent(
|
||||
activity: Activity.running,
|
||||
startedAt: start,
|
||||
endedAt: start.add(const Duration(minutes: 25)),
|
||||
name: 'Along the river',
|
||||
distanceMetres: 5100,
|
||||
);
|
||||
final heartRate = chunksOf(
|
||||
SeriesKind.heartRate,
|
||||
start: start,
|
||||
times: [for (var s = 0; s < 25 * 60; s += 10) s * 1000],
|
||||
columns: {
|
||||
'bpm': [for (var s = 0; s < 25 * 60; s += 10) 110 + s % 50],
|
||||
},
|
||||
);
|
||||
|
||||
/// A device of the account whose keys are [account] (none: signed in
|
||||
/// without them), on the shared rows.
|
||||
Future<WorkoutRepository> device(UnlockedAccountKeys? account) async {
|
||||
final keys = TrainKeys(keyRows, SodiumContentCipher(), () async => account);
|
||||
opened.add(keys);
|
||||
await keys.refresh();
|
||||
return WorkoutRepository(rows, keys);
|
||||
}
|
||||
|
||||
setUpAll(() async {
|
||||
alice = (await crypto.newAccount('alice password')).keys;
|
||||
});
|
||||
|
||||
setUp(() {
|
||||
keyRows = MemoryContentKeyGateway();
|
||||
rows = MemoryWorkoutGateway();
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
for (final keys in opened) {
|
||||
keys.dispose();
|
||||
}
|
||||
opened.clear();
|
||||
});
|
||||
|
||||
test('a workout saved on one device reads on another of the account, its '
|
||||
'series in time order', () async {
|
||||
final phone = await device(alice);
|
||||
final id = await phone.save(run, heartRate.reversed.toList());
|
||||
|
||||
final laptop = await device(alice);
|
||||
|
||||
expect(await laptop.all(), [StoredWorkout(id, run)]);
|
||||
expect(await laptop.series(id), heartRate);
|
||||
});
|
||||
|
||||
test('nothing the instance gets is readable: every row is sealed, no '
|
||||
'name, no JSON', () async {
|
||||
final phone = await device(alice);
|
||||
await phone.save(run, heartRate);
|
||||
|
||||
expect(rows.written, hasLength(1 + heartRate.length));
|
||||
for (final envelope in rows.written) {
|
||||
expect(envelope.keyId, startsWith('train1.'));
|
||||
expect(envelope.nonce, isNotNull);
|
||||
final bytes = Uint8List.sublistView(envelope.payload);
|
||||
expect(
|
||||
utf8.decode(bytes, allowMalformed: true),
|
||||
isNot(contains('river')),
|
||||
);
|
||||
expect(bytes.take(2), isNot([0x1f, 0x8b]));
|
||||
}
|
||||
});
|
||||
|
||||
test('an edit replaces the workout and keeps its series', () async {
|
||||
final phone = await device(alice);
|
||||
final id = await phone.save(run, heartRate);
|
||||
final renamed = WorkoutContent(
|
||||
activity: run.activity,
|
||||
startedAt: run.startedAt,
|
||||
endedAt: run.endedAt,
|
||||
name: 'Morning run',
|
||||
distanceMetres: run.distanceMetres,
|
||||
);
|
||||
|
||||
await phone.update(id, renamed);
|
||||
|
||||
expect(await phone.all(), [StoredWorkout(id, renamed)]);
|
||||
expect(await phone.series(id), heartRate);
|
||||
});
|
||||
|
||||
test("a device without the account's keys sees workouts locked and saves "
|
||||
'none', () async {
|
||||
final phone = await device(alice);
|
||||
final id = await phone.save(run, heartRate);
|
||||
final writes = rows.written.length;
|
||||
|
||||
final elsewhere = await device(null);
|
||||
|
||||
expect(await elsewhere.all(), [StoredWorkout(id, null)]);
|
||||
expect(await elsewhere.series(id), isEmpty);
|
||||
await expectLater(
|
||||
elsewhere.save(run, heartRate),
|
||||
throwsA(isA<KeysMissing>()),
|
||||
);
|
||||
expect(rows.written, hasLength(writes));
|
||||
});
|
||||
|
||||
test(
|
||||
"a series the instance moves to another workout doesn't open there",
|
||||
() async {
|
||||
final phone = await device(alice);
|
||||
final first = await phone.save(run, heartRate.take(1).toList());
|
||||
final second = await phone.save(run, heartRate.skip(1).take(1).toList());
|
||||
final moved = rows.seriesRows.firstWhere((row) => row.workoutId == first);
|
||||
rows.seriesRows
|
||||
..remove(moved)
|
||||
..add((id: moved.id, workoutId: second, envelope: moved.envelope));
|
||||
|
||||
expect(await phone.series(first), isEmpty);
|
||||
expect(await phone.series(second), [heartRate[1]]);
|
||||
},
|
||||
);
|
||||
|
||||
test('a workout the instance made up, readable, or one it changed, is '
|
||||
"locked, never the person's", () async {
|
||||
final phone = await device(alice);
|
||||
final id = await phone.save(run, heartRate);
|
||||
final sealed = rows.workoutRows[id]!;
|
||||
final changed = Uint8List.fromList(Uint8List.sublistView(sealed.payload));
|
||||
changed[changed.length - 1] ^= 1;
|
||||
rows.workoutRows[id] = (
|
||||
keyId: sealed.keyId,
|
||||
nonce: sealed.nonce,
|
||||
payload: ByteData.sublistView(changed),
|
||||
);
|
||||
rows.workoutRows['made-up'] = (
|
||||
keyId: null,
|
||||
nonce: null,
|
||||
payload: ByteData.sublistView(
|
||||
Uint8List.fromList(
|
||||
utf8.encode(
|
||||
'{"schemaVersion":1,"activity":"running","startedAt":0,'
|
||||
'"endedAt":0}',
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(await phone.all(), [
|
||||
StoredWorkout(id, null),
|
||||
const StoredWorkout('made-up', null),
|
||||
]);
|
||||
});
|
||||
|
||||
test('deleting a workout deletes its series', () async {
|
||||
final phone = await device(alice);
|
||||
final id = await phone.save(run, heartRate);
|
||||
final kept = await phone.save(run, heartRate.take(1).toList());
|
||||
|
||||
await phone.delete(id);
|
||||
|
||||
expect(await phone.all(), [StoredWorkout(kept, run)]);
|
||||
expect(rows.seriesRows.map((row) => row.workoutId), [kept]);
|
||||
});
|
||||
|
||||
test('changes from other devices are announced', () async {
|
||||
final phone = await device(alice);
|
||||
final announced = expectLater(phone.changes, emits(null));
|
||||
|
||||
rows.bringRemoteChanges();
|
||||
|
||||
await announced;
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue