todo-app/test/cli/cli_session_test.dart
Krzysztof kuhy Rudnicki 19f930a93a
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file-length / file length <= 250 lines (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
CLI caches the E2E key (0600)
Argon2id ran on every CLI/MCP call; the derived key is now cached
owner-only in the data dir.

Todo: fda71ed1

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 10:59:34 +01:00

161 lines
4.9 KiB
Dart

import 'dart:io';
import 'package:crdt_sync/crdt_sync.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:todo/cli/cli_session.dart';
import 'package:todo/data/note.dart';
import 'fake_rtdb.dart';
void main() {
late Directory tmp;
late FakeRtdb db;
late String seed;
setUp(() {
tmp = Directory.systemTemp.createTempSync('cli_session_test');
db = FakeRtdb();
seed = writeSeedCredentials(tmp).path;
});
tearDown(() => tmp.deleteSync(recursive: true));
Future<CliSession> open(String name, {String? seedPath}) => CliSession.open(
'${tmp.path}/$name',
seedCredentials: seedPath ?? seed,
httpClient: db.client,
);
Note note(String id) {
final now = DateTime.utc(2026);
return Note(
id: id,
text: 'Title\nbody',
priority: Priority.medium,
status: Status.todo,
createdAt: now,
updatedAt: now,
);
}
group('defaultDataDir', () {
test('prefers XDG_DATA_HOME', () {
expect(
defaultDataDir({'XDG_DATA_HOME': '/x', 'HOME': '/h'}),
'/x/todo-cli',
);
});
test('falls back to ~/.local/share', () {
expect(defaultDataDir({'HOME': '/h'}), '/h/.local/share/todo-cli');
expect(
defaultDataDir({'XDG_DATA_HOME': '', 'HOME': '/h'}),
'/h/.local/share/todo-cli',
);
});
test('survives a missing HOME', () {
expect(defaultDataDir({}), '.local/share/todo-cli');
});
});
group('open', () {
test('creates an owner-only data dir and persists one node id', () async {
final first = await open('data');
final nodeId = first.repository.nodeId;
await first.close();
final dir = Directory('${tmp.path}/data');
expect(dir.statSync().mode & 0x1ff, 448); // 0700
expect(
File('${dir.path}/${CliSession.nodeIdFileName}').readAsStringSync(),
'$nodeId\n',
);
final second = await open('data');
expect(second.repository.nodeId, nodeId);
await second.close();
});
test('mints a fresh id when the stored one is blank', () async {
Directory('${tmp.path}/blank').createSync();
File(
'${tmp.path}/blank/${CliSession.nodeIdFileName}',
).writeAsStringSync(' \n');
final session = await open('blank');
expect(session.repository.nodeId, isNotEmpty);
await session.close();
});
test('fails without a Firebase session, naming the seed', () async {
final missing = '${tmp.path}/missing.json';
await expectLater(
open('nosession', seedPath: missing),
throwsA(
isA<FirebaseAuthError>().having(
(e) => e.toString(),
'message',
contains(missing),
),
),
);
});
});
group('sync', () {
test('a pull-only sync never writes to the database', () async {
final session = await open('pull');
await session.repository.upsert(note('n1'));
final result = await session.sync(push: false);
expect(result.pushed, isTrue); // SyncService reports its own intent.
expect(db.methods, isNot(contains('PUT')));
expect(db.files, isEmpty);
await session.close();
});
test('a push publishes the log, then skips an unchanged one', () async {
final session = await open('push');
await session.repository.upsert(note('n1'));
final first = await session.sync(push: true);
expect(first.pushed, isTrue);
final node = session.repository.nodeId;
expect(db.files.keys, contains('todo-sync/notes-e2e/$node~2Ejson'));
expect(db.files.keys, contains('todo-sync/revs-e2e/$node'));
expect(
File('${tmp.path}/push/${CliSession.syncStateFileName}').existsSync(),
isTrue,
);
final second = await session.sync(push: true);
expect(second.pushed, isFalse);
await session.close();
});
test('the derived key is cached owner-only for the next run', () async {
final first = await open('cache');
await first.repository.upsert(note('n1'));
await first.sync(push: true);
await first.close();
final cached = File('${tmp.path}/cache/${CliSession.e2eKeyFileName}');
expect(cached.existsSync(), isTrue);
expect(cached.statSync().mode & 0x1ff, 384); // 0600
// Without any passphrase the next run still unlocks from the cache.
File('${tmp.path}/e2e_passphrase').deleteSync();
final second = await open('cache');
expect((await second.sync(push: true)).pushed, isFalse);
await second.close();
});
test('a second node sees the first node note', () async {
final a = await open('a');
await a.repository.upsert(note('shared'));
await a.sync(push: true);
await a.close();
final b = await open('b');
final result = await b.sync(push: false);
expect(result.mergedDevices, 1);
expect((await b.repository.listNotes()).single.id, 'shared');
await b.close();
});
});
}