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:
Tizian 「ティツィアーン」 2026-10-28 11:24:20 +01:00
commit d1daec9c0b
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
2 changed files with 801 additions and 0 deletions

View file

@ -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);
}
});
}

View file

@ -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;
});
}