network addition
This commit is contained in:
parent
3bd713d667
commit
2099d3d64d
27 changed files with 2237 additions and 40 deletions
173
test/remote_integration_test.dart
Normal file
173
test/remote_integration_test.dart
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
@Timeout(Duration(seconds: 15))
|
||||
library;
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:timbre/playback/playback_engine.dart';
|
||||
import 'package:timbre/remote/discovery.dart';
|
||||
import 'package:timbre/remote/host_server.dart';
|
||||
import 'package:timbre/remote/messages.dart';
|
||||
import 'package:timbre/remote/remote_session.dart';
|
||||
import 'package:timbre/subsonic/models.dart';
|
||||
|
||||
/// End-to-end over a real loopback WebSocket: a [RemoteHost] driving a
|
||||
/// (headless, audio-unsupported) [PlaybackController] and a [RemoteSession]
|
||||
/// client in the same process. Exercises the handshake, HMAC auth, snapshot
|
||||
/// broadcast, and command application — the whole stack minus the platform
|
||||
/// audio backend and mDNS discovery.
|
||||
void main() {
|
||||
const serverKey = 'server-key';
|
||||
const password = 'hunter2';
|
||||
|
||||
late PlaybackController controller;
|
||||
late RemoteHost host;
|
||||
final sessions = <RemoteSession>[];
|
||||
|
||||
PlaybackController buildController() => PlaybackController(
|
||||
// Non-null so songs count as streamable; no real player exists on the
|
||||
// test host (audio unsupported), so this URI is never opened.
|
||||
streamUriFor: (s) => Uri.parse('http://host.local/stream/${s.id}'),
|
||||
coverArtUriFor: (_) => null,
|
||||
serverKeyGetter: () => serverKey,
|
||||
onArt: (_) {},
|
||||
onPlay: (_) {},
|
||||
);
|
||||
|
||||
RemoteSession newSession(
|
||||
int port, {
|
||||
String key = serverKey,
|
||||
String pass = password,
|
||||
}) {
|
||||
final s = RemoteSession(
|
||||
device: DiscoveredDevice(
|
||||
name: 'Host',
|
||||
host: '127.0.0.1',
|
||||
port: port,
|
||||
serverKey: key,
|
||||
version: kRemoteProtocolVersion,
|
||||
),
|
||||
serverKey: key,
|
||||
password: pass,
|
||||
deviceName: 'Remote',
|
||||
);
|
||||
sessions.add(s);
|
||||
return s;
|
||||
}
|
||||
|
||||
Song song(String id) => Song(id: id, title: 'Song $id');
|
||||
|
||||
// Subscribe to the "connected" event *before* dialing, so a fast handshake
|
||||
// can't fire it before we're listening (broadcast streams drop unheard
|
||||
// events).
|
||||
Future<void> connectAuthed(RemoteSession s) async {
|
||||
final connected =
|
||||
s.status.firstWhere((x) => x == RemoteConnStatus.connected);
|
||||
await s.connect();
|
||||
await connected;
|
||||
}
|
||||
|
||||
setUp(() async {
|
||||
controller = buildController();
|
||||
host = RemoteHost(
|
||||
controller: controller,
|
||||
serverKey: serverKey,
|
||||
password: password,
|
||||
deviceName: 'Host',
|
||||
);
|
||||
await host.start();
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
for (final s in sessions) {
|
||||
await s.close();
|
||||
}
|
||||
sessions.clear();
|
||||
await host.stop();
|
||||
controller.dispose();
|
||||
});
|
||||
|
||||
test('valid remote completes the handshake and receives a snapshot',
|
||||
() async {
|
||||
final session = newSession(host.port!);
|
||||
final firstSnapshot = session.snapshots.first;
|
||||
await connectAuthed(session);
|
||||
expect(session.hostDevice, 'Host');
|
||||
await firstSnapshot; // initial state pushed on connect
|
||||
});
|
||||
|
||||
test('host pushes a snapshot when local playback changes', () async {
|
||||
final session = newSession(host.port!);
|
||||
await connectAuthed(session);
|
||||
|
||||
final gotQueue =
|
||||
session.snapshots.firstWhere((s) => s.queue.length == 2);
|
||||
await controller.playSongs([song('a'), song('b')]);
|
||||
|
||||
final snap = await gotQueue;
|
||||
expect(snap.queue.map((s) => s.id), ['a', 'b']);
|
||||
expect(snap.currentIndex, 0);
|
||||
});
|
||||
|
||||
test('a command from the remote drives the host engine', () async {
|
||||
await controller.playSongs([song('a'), song('b'), song('c')]);
|
||||
|
||||
final session = newSession(host.port!);
|
||||
await connectAuthed(session);
|
||||
|
||||
// Jump to index 2 from the remote; the host engine should follow, and a
|
||||
// fresh snapshot should reflect it.
|
||||
final jumped =
|
||||
session.snapshots.firstWhere((s) => s.currentIndex == 2);
|
||||
session.send(const RemoteCommand.jumpTo(2));
|
||||
|
||||
final snap = await jumped;
|
||||
expect(snap.currentIndex, 2);
|
||||
expect(controller.currentState.currentIndex, 2);
|
||||
});
|
||||
|
||||
test('remote can enqueue a track by sending its Song', () async {
|
||||
await controller.playSongs([song('a')]);
|
||||
|
||||
final session = newSession(host.port!);
|
||||
await connectAuthed(session);
|
||||
|
||||
final grew = session.snapshots.firstWhere((s) => s.queue.length == 2);
|
||||
session.send(RemoteCommand.addToQueue(song('z')));
|
||||
|
||||
final snap = await grew;
|
||||
expect(snap.queue.map((s) => s.id), ['a', 'z']);
|
||||
});
|
||||
|
||||
test('wrong password is denied', () async {
|
||||
final session = newSession(host.port!, pass: 'wrong-password');
|
||||
final denied =
|
||||
session.status.firstWhere((s) => s == RemoteConnStatus.denied);
|
||||
await session.connect();
|
||||
await denied;
|
||||
expect(host.clientCount, 0);
|
||||
});
|
||||
|
||||
test('different server key is denied', () async {
|
||||
final session = newSession(host.port!, key: 'other-server');
|
||||
final denied =
|
||||
session.status.firstWhere((s) => s == RemoteConnStatus.denied);
|
||||
await session.connect();
|
||||
await denied;
|
||||
expect(host.clientCount, 0);
|
||||
});
|
||||
|
||||
test('a host broadcast reaches every connected remote', () async {
|
||||
final a = newSession(host.port!);
|
||||
final b = newSession(host.port!);
|
||||
await connectAuthed(a);
|
||||
await connectAuthed(b);
|
||||
expect(host.clientCount, 2);
|
||||
|
||||
// Subscribe both before the host changes, then drive from the host: both
|
||||
// remotes must observe the new queue.
|
||||
final aSees = a.snapshots.firstWhere((s) => s.queue.length == 1);
|
||||
final bSees = b.snapshots.firstWhere((s) => s.queue.length == 1);
|
||||
await controller.playSongs([song('a')]);
|
||||
await aSees;
|
||||
await bSees;
|
||||
});
|
||||
}
|
||||
157
test/remote_protocol_test.dart
Normal file
157
test/remote_protocol_test.dart
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue