157 lines
5.1 KiB
Dart
157 lines
5.1 KiB
Dart
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:timbre/remote/messages.dart';
|
|
import 'package:timbre/subsonic/models.dart';
|
|
|
|
Song _song(String id, {String? title}) => Song(id: id, title: title ?? id);
|
|
|
|
void main() {
|
|
group('RemoteAuth', () {
|
|
test('proof matches when the password matches', () {
|
|
const nonce = 'abc123';
|
|
final proof = RemoteAuth.proofFor(password: 'hunter2', nonce: nonce);
|
|
expect(
|
|
RemoteAuth.verify(password: 'hunter2', nonce: nonce, proof: proof),
|
|
isTrue,
|
|
);
|
|
});
|
|
|
|
test('proof fails on the wrong password', () {
|
|
const nonce = 'abc123';
|
|
final proof = RemoteAuth.proofFor(password: 'hunter2', nonce: nonce);
|
|
expect(
|
|
RemoteAuth.verify(password: 'wrong', nonce: nonce, proof: proof),
|
|
isFalse,
|
|
);
|
|
});
|
|
|
|
test('proof is nonce-bound (no replay across connections)', () {
|
|
final p1 = RemoteAuth.proofFor(password: 'pw', nonce: 'n1');
|
|
final p2 = RemoteAuth.proofFor(password: 'pw', nonce: 'n2');
|
|
expect(p1, isNot(p2));
|
|
expect(RemoteAuth.verify(password: 'pw', nonce: 'n2', proof: p1), isFalse);
|
|
});
|
|
|
|
test('newNonce is fresh each call', () {
|
|
expect(RemoteAuth.newNonce(), isNot(RemoteAuth.newNonce()));
|
|
});
|
|
});
|
|
|
|
group('StateSnapshot codec', () {
|
|
test('round-trips through JSON', () {
|
|
final snap = StateSnapshot(
|
|
queue: [_song('1', title: 'One'), _song('2', title: 'Two')],
|
|
currentIndex: 1,
|
|
playing: true,
|
|
positionMs: 42000,
|
|
durationMs: 180000,
|
|
shuffle: true,
|
|
loop: 'all',
|
|
seq: 7,
|
|
);
|
|
final back = StateSnapshot.fromJson(
|
|
StateSnapshot.fromJson(snap.toJson()).toJson(),
|
|
);
|
|
expect(back.queue.map((s) => s.id), ['1', '2']);
|
|
expect(back.currentIndex, 1);
|
|
expect(back.playing, isTrue);
|
|
expect(back.positionMs, 42000);
|
|
expect(back.durationMs, 180000);
|
|
expect(back.shuffle, isTrue);
|
|
expect(back.loop, 'all');
|
|
expect(back.seq, 7);
|
|
});
|
|
|
|
test('tolerates a missing/empty queue', () {
|
|
final snap = StateSnapshot.fromJson({'playing': false});
|
|
expect(snap.queue, isEmpty);
|
|
expect(snap.currentIndex, isNull);
|
|
expect(snap.loop, 'off');
|
|
});
|
|
});
|
|
|
|
group('RemoteCommand codec', () {
|
|
test('payload-carrying ops round-trip', () {
|
|
final cmds = <RemoteCommand>[
|
|
const RemoteCommand.playPause(),
|
|
const RemoteCommand.seek(12345),
|
|
const RemoteCommand.jumpTo(3),
|
|
const RemoteCommand.removeAt(2),
|
|
const RemoteCommand.reorder(1, 4),
|
|
RemoteCommand.playSongs([_song('a'), _song('b')], startIndex: 1),
|
|
RemoteCommand.playNext(_song('c')),
|
|
RemoteCommand.addToQueue(_song('d')),
|
|
const RemoteCommand.toggleShuffle(),
|
|
const RemoteCommand.cycleLoop(),
|
|
];
|
|
for (final c in cmds) {
|
|
final back = RemoteCommand.fromJson(c.toJson());
|
|
expect(back.op, c.op);
|
|
expect(back.index, c.index);
|
|
expect(back.oldIndex, c.oldIndex);
|
|
expect(back.newIndex, c.newIndex);
|
|
expect(back.startIndex, c.startIndex);
|
|
expect(back.positionMs, c.positionMs);
|
|
expect(back.song?.id, c.song?.id);
|
|
expect(back.songs?.map((s) => s.id), c.songs?.map((s) => s.id));
|
|
}
|
|
});
|
|
|
|
test('unknown op throws', () {
|
|
expect(
|
|
() => RemoteCommand.fromJson({'op': 'nope'}),
|
|
throwsFormatException,
|
|
);
|
|
});
|
|
});
|
|
|
|
group('RemoteMessage envelope', () {
|
|
test('encodes and decodes each message type', () {
|
|
final msgs = <RemoteMessage>[
|
|
const ChallengeMessage(
|
|
version: 1, serverKey: 'k', nonce: 'n', device: 'iPad'),
|
|
const AuthMessage(
|
|
version: 1, serverKey: 'k', proof: 'p', device: 'Phone'),
|
|
const WelcomeMessage(device: 'iPad'),
|
|
const DenyMessage(reason: 'bad auth'),
|
|
SnapshotMessage(StateSnapshot(
|
|
queue: [_song('1')],
|
|
currentIndex: 0,
|
|
playing: false,
|
|
positionMs: 0,
|
|
durationMs: 0,
|
|
shuffle: false,
|
|
loop: 'off',
|
|
seq: 1,
|
|
)),
|
|
const CommandMessage(RemoteCommand.next()),
|
|
];
|
|
for (final m in msgs) {
|
|
final back = RemoteMessage.decode(m.encode());
|
|
expect(back.runtimeType, m.runtimeType);
|
|
}
|
|
});
|
|
|
|
test('decodes handshake fields', () {
|
|
final decoded = RemoteMessage.decode(const ChallengeMessage(
|
|
version: kRemoteProtocolVersion,
|
|
serverKey: 'server-key',
|
|
nonce: 'the-nonce',
|
|
device: 'Living Room iPad',
|
|
).encode());
|
|
expect(decoded, isA<ChallengeMessage>());
|
|
final c = decoded as ChallengeMessage;
|
|
expect(c.version, kRemoteProtocolVersion);
|
|
expect(c.serverKey, 'server-key');
|
|
expect(c.nonce, 'the-nonce');
|
|
expect(c.device, 'Living Room iPad');
|
|
});
|
|
|
|
test('rejects a non-object frame', () {
|
|
expect(() => RemoteMessage.decode('42'), throwsFormatException);
|
|
});
|
|
|
|
test('rejects an unknown message type', () {
|
|
expect(() => RemoteMessage.decode('{"t":"bogus"}'), throwsFormatException);
|
|
});
|
|
});
|
|
}
|