You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
232 lines
7.2 KiB
232 lines
7.2 KiB
import 'dart:typed_data';
|
|
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:meshcore_open/connector/meshcore_protocol.dart';
|
|
import 'package:meshcore_open/models/contact.dart';
|
|
|
|
/// A synthetic 64-hex key. Not a real node.
|
|
const _key = '00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff';
|
|
|
|
void main() {
|
|
group('Contact share URI (#625)', () {
|
|
test('parses the documented spec example verbatim', () {
|
|
// Straight from the firmware docs/qr_codes.md "Add Contact" example.
|
|
final c = Contact.fromShareUri(
|
|
'meshcore://contact/add'
|
|
'?name=Example+Contact'
|
|
'&public_key=9cd8fcf22a47333b591d96a2b848b73f457b1bb1a3ea2453a885f9e5787765b1'
|
|
'&type=1',
|
|
);
|
|
expect(c, isNotNull);
|
|
// `+` must decode to a space, or stock-emitted names arrive mangled.
|
|
expect(c!.name, 'Example Contact');
|
|
expect(
|
|
c.publicKeyHex,
|
|
'9cd8fcf22a47333b591d96a2b848b73f457b1bb1a3ea2453a885f9e5787765b1',
|
|
);
|
|
expect(c.type, advTypeChat);
|
|
});
|
|
|
|
test('percent-encoded names round-trip, including emoji', () {
|
|
final c = Contact.fromShareUri(
|
|
'meshcore://contact/add?name=${Uri.encodeComponent('DIRT WIZARD 🧙')}'
|
|
'&public_key=$_key&type=1',
|
|
);
|
|
expect(c, isNotNull);
|
|
expect(c!.name, 'DIRT WIZARD 🧙');
|
|
});
|
|
|
|
test('a parsed contact is an unverified stub, not a routed contact', () {
|
|
final c = Contact.fromShareUri(
|
|
'meshcore://contact/add?name=Bob&public_key=$_key&type=1',
|
|
)!;
|
|
|
|
// The load-bearing assertion for #620: lastSeen maps to the firmware's
|
|
// last_advert_timestamp. Anything other than the epoch would trip the
|
|
// advert replay guard and leave this contact permanently deaf to its
|
|
// own adverts.
|
|
expect(c.lastSeen, DateTime.fromMillisecondsSinceEpoch(0));
|
|
|
|
// Flood until a path is learned.
|
|
expect(c.pathLength, -1);
|
|
expect(c.path, isEmpty);
|
|
|
|
// No signed advert, so it cannot be re-shared yet.
|
|
expect(c.rawPacket, isNull);
|
|
expect(c.flags, 0);
|
|
});
|
|
|
|
test('type is optional and defaults to companion', () {
|
|
final c = Contact.fromShareUri(
|
|
'meshcore://contact/add?name=Bob&public_key=$_key',
|
|
);
|
|
expect(c, isNotNull);
|
|
expect(c!.type, advTypeChat);
|
|
});
|
|
|
|
test('accepts every documented contact type', () {
|
|
for (final t in [
|
|
advTypeChat,
|
|
advTypeRepeater,
|
|
advTypeRoom,
|
|
advTypeSensor,
|
|
]) {
|
|
final c = Contact.fromShareUri(
|
|
'meshcore://contact/add?name=N&public_key=$_key&type=$t',
|
|
);
|
|
expect(c, isNotNull, reason: 'type $t should parse');
|
|
expect(c!.type, t);
|
|
}
|
|
});
|
|
|
|
test('rejects an out-of-range or non-numeric type', () {
|
|
for (final t in ['0', '5', '255', '-1', 'chat', '1.5']) {
|
|
expect(
|
|
Contact.fromShareUri(
|
|
'meshcore://contact/add?name=N&public_key=$_key&type=$t',
|
|
),
|
|
isNull,
|
|
reason: 'type "$t" should be rejected',
|
|
);
|
|
}
|
|
});
|
|
|
|
test('a missing name falls back rather than failing the add', () {
|
|
final c = Contact.fromShareUri(
|
|
'meshcore://contact/add?public_key=$_key&type=1',
|
|
);
|
|
expect(c, isNotNull);
|
|
expect(c!.name, 'Unknown');
|
|
});
|
|
|
|
test('rejects a malformed or wrong-length public key', () {
|
|
final bad = <String>[
|
|
'', // absent value
|
|
'00112233', // too short
|
|
'${_key}ff', // too long
|
|
_key.replaceRange(0, 2, 'zz'), // right length, not hex
|
|
];
|
|
for (final k in bad) {
|
|
expect(
|
|
Contact.fromShareUri(
|
|
'meshcore://contact/add?name=N&public_key=$k&type=1',
|
|
),
|
|
isNull,
|
|
reason: 'key "$k" should be rejected',
|
|
);
|
|
}
|
|
// Entirely absent parameter.
|
|
expect(
|
|
Contact.fromShareUri('meshcore://contact/add?name=N&type=1'),
|
|
isNull,
|
|
);
|
|
});
|
|
|
|
test('rejects the wrong scheme, host or path', () {
|
|
final bad = <String>[
|
|
'https://contact/add?public_key=$_key',
|
|
'meshcore://channel/add?public_key=$_key',
|
|
'meshcore://contact/remove?public_key=$_key',
|
|
'meshcore://contact?public_key=$_key',
|
|
'not a uri at all',
|
|
'',
|
|
];
|
|
for (final u in bad) {
|
|
expect(Contact.fromShareUri(u), isNull, reason: '"$u" should reject');
|
|
}
|
|
});
|
|
|
|
test('leading and trailing whitespace is tolerated', () {
|
|
expect(
|
|
Contact.fromShareUri(
|
|
' meshcore://contact/add?name=N&public_key=$_key&type=1\n',
|
|
),
|
|
isNotNull,
|
|
);
|
|
});
|
|
|
|
test(
|
|
'returns null for the legacy advert-hex form so callers fall back',
|
|
() {
|
|
// The fork's older share format is `meshcore://<raw advert hex>`, whose
|
|
// host is the hex itself. It carries a full signed advert and must keep
|
|
// going down its own import path, not this one.
|
|
expect(Contact.fromShareUri('meshcore://${_key}aabbcc'), isNull);
|
|
},
|
|
);
|
|
|
|
test('isValidShareUri agrees with fromShareUri', () {
|
|
const good = 'meshcore://contact/add?name=N&public_key=$_key&type=2';
|
|
expect(Contact.isValidShareUri(good), isTrue);
|
|
expect(Contact.isValidShareUri('meshcore://channel/add?name=x'), isFalse);
|
|
});
|
|
});
|
|
|
|
group('Contact share URI emit (#626)', () {
|
|
Contact stub(String name, int type) => Contact(
|
|
publicKey: hex2Uint8List(_key),
|
|
name: name,
|
|
type: type,
|
|
pathLength: -1,
|
|
path: Uint8List(0),
|
|
lastSeen: DateTime.fromMillisecondsSinceEpoch(0),
|
|
);
|
|
|
|
test('buildShareUri emits the documented parameter shape', () {
|
|
expect(
|
|
Contact.buildShareUri(publicKeyHex: _key, name: 'Bob', type: 2),
|
|
'meshcore://contact/add?name=Bob&public_key=$_key&type=2',
|
|
);
|
|
});
|
|
|
|
test('type defaults to companion when not supplied', () {
|
|
expect(
|
|
Contact.buildShareUri(publicKeyHex: _key, name: 'Bob'),
|
|
endsWith('&type=$advTypeChat'),
|
|
);
|
|
});
|
|
|
|
test('toShareUri round-trips through fromShareUri', () {
|
|
for (final name in [
|
|
'Bob',
|
|
'Two Words',
|
|
'DIRT WIZARD 🧙',
|
|
'amp&equals=hash#q',
|
|
'Ka8sbi',
|
|
]) {
|
|
final original = stub(name, advTypeRepeater);
|
|
final back = Contact.fromShareUri(original.toShareUri());
|
|
expect(back, isNotNull, reason: 'name "$name" should round-trip');
|
|
expect(back!.name, name);
|
|
expect(back.publicKeyHex, original.publicKeyHex);
|
|
expect(back.type, original.type);
|
|
}
|
|
});
|
|
|
|
test('reserved characters in a name are encoded, not emitted raw', () {
|
|
// A raw & or = would silently truncate or forge query parameters.
|
|
final uri = stub('a&b=c', advTypeChat).toShareUri();
|
|
expect(uri.contains('name=a&b=c'), isFalse);
|
|
expect(Contact.fromShareUri(uri)!.name, 'a&b=c');
|
|
});
|
|
|
|
test('every documented type survives a round-trip', () {
|
|
for (final t in [
|
|
advTypeChat,
|
|
advTypeRepeater,
|
|
advTypeRoom,
|
|
advTypeSensor,
|
|
]) {
|
|
expect(Contact.fromShareUri(stub('N', t).toShareUri())!.type, t);
|
|
}
|
|
});
|
|
|
|
test('what we emit is what we accept', () {
|
|
expect(
|
|
Contact.isValidShareUri(stub('N', advTypeChat).toShareUri()),
|
|
true,
|
|
);
|
|
});
|
|
});
|
|
}
|