feat: import Chinese-localized Buzz source snapshot
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled
Signed-off-by: cls_宁波本机 <908705107@qq.com>
This commit is contained in:
@@ -0,0 +1,469 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:buzz/shared/relay/animated_image_sanitizer.dart';
|
||||
|
||||
void main() {
|
||||
test('strips APNG metadata without changing animation chunks', () {
|
||||
final clean = _animatedPng(metadata: false);
|
||||
final dirty = _animatedPng(metadata: true)
|
||||
..addAll(utf8.encode('trailing metadata'));
|
||||
|
||||
expect(
|
||||
sanitizeAnimatedImageForUpload(Uint8List.fromList(dirty), 'image/png'),
|
||||
clean,
|
||||
);
|
||||
});
|
||||
|
||||
test('strips identity APNG orientation but rejects display transforms', () {
|
||||
final clean = _animatedPng(metadata: false);
|
||||
expect(
|
||||
sanitizeAnimatedImageForUpload(
|
||||
Uint8List.fromList(_animatedPng(metadata: false, orientation: 1)),
|
||||
'image/png',
|
||||
),
|
||||
clean,
|
||||
);
|
||||
|
||||
for (final endian in [Endian.little, Endian.big]) {
|
||||
expect(
|
||||
() => sanitizeAnimatedImageForUpload(
|
||||
Uint8List.fromList(
|
||||
_animatedPng(
|
||||
metadata: false,
|
||||
orientation: 6,
|
||||
orientationEndian: endian,
|
||||
),
|
||||
),
|
||||
'image/png',
|
||||
),
|
||||
throwsA(
|
||||
isA<FormatException>().having(
|
||||
(error) => error.message,
|
||||
'message',
|
||||
contains('orientation'),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects APNG ICC profiles that affect color rendering', () {
|
||||
expect(
|
||||
() => sanitizeAnimatedImageForUpload(
|
||||
Uint8List.fromList(_animatedPng(metadata: false, iccProfile: true)),
|
||||
'image/png',
|
||||
),
|
||||
throwsA(
|
||||
isA<FormatException>().having(
|
||||
(error) => error.message,
|
||||
'message',
|
||||
contains('ICC profile'),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('strips animated WebP metadata and clears metadata flags', () {
|
||||
final clean = _animatedWebp(metadata: false);
|
||||
final dirty = _animatedWebp(metadata: true)
|
||||
..addAll(utf8.encode('trailing metadata'));
|
||||
|
||||
expect(
|
||||
sanitizeAnimatedImageForUpload(Uint8List.fromList(dirty), 'image/webp'),
|
||||
clean,
|
||||
);
|
||||
});
|
||||
|
||||
test('strips identity WebP orientation but rejects display transforms', () {
|
||||
final clean = _animatedWebp(metadata: false);
|
||||
expect(
|
||||
sanitizeAnimatedImageForUpload(
|
||||
Uint8List.fromList(_animatedWebp(metadata: false, orientation: 1)),
|
||||
'image/webp',
|
||||
),
|
||||
clean,
|
||||
);
|
||||
|
||||
for (final endian in [Endian.little, Endian.big]) {
|
||||
expect(
|
||||
() => sanitizeAnimatedImageForUpload(
|
||||
Uint8List.fromList(
|
||||
_animatedWebp(
|
||||
metadata: false,
|
||||
orientation: 6,
|
||||
orientationEndian: endian,
|
||||
),
|
||||
),
|
||||
'image/webp',
|
||||
),
|
||||
throwsA(
|
||||
isA<FormatException>().having(
|
||||
(error) => error.message,
|
||||
'message',
|
||||
contains('orientation'),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects animated WebP ICC profiles that affect color rendering', () {
|
||||
expect(
|
||||
() => sanitizeAnimatedImageForUpload(
|
||||
Uint8List.fromList(_animatedWebp(metadata: false, iccProfile: true)),
|
||||
'image/webp',
|
||||
),
|
||||
throwsA(
|
||||
isA<FormatException>().having(
|
||||
(error) => error.message,
|
||||
'message',
|
||||
contains('ICC profile'),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('removes metadata chunks nested inside animated WebP frames', () {
|
||||
expect(
|
||||
sanitizeAnimatedImageForUpload(
|
||||
Uint8List.fromList(
|
||||
_animatedWebp(metadata: false, nestedMetadata: true),
|
||||
),
|
||||
'image/webp',
|
||||
),
|
||||
_animatedWebp(metadata: false),
|
||||
);
|
||||
});
|
||||
|
||||
test('strips GIF metadata without changing animation blocks', () {
|
||||
final clean = _minimalGif();
|
||||
final dirty = <int>[
|
||||
...clean.sublist(0, 19),
|
||||
..._gifCommentExtension(),
|
||||
..._gifApplicationExtension('XMP DataXMP', utf8.encode('<x/>')),
|
||||
...clean.sublist(19),
|
||||
...utf8.encode('trailing metadata'),
|
||||
];
|
||||
|
||||
expect(
|
||||
sanitizeAnimatedImageForUpload(Uint8List.fromList(dirty), 'image/gif'),
|
||||
clean,
|
||||
);
|
||||
});
|
||||
|
||||
test('canonicalizes GIF loop extensions with hidden sub-blocks', () {
|
||||
final clean = _minimalGif();
|
||||
final cleanLoop = _gifLoopExtension();
|
||||
final dirty = <int>[
|
||||
...clean.sublist(0, 19),
|
||||
..._gifLoopExtension(extra: utf8.encode('location')),
|
||||
...clean.sublist(19 + cleanLoop.length),
|
||||
];
|
||||
|
||||
expect(
|
||||
sanitizeAnimatedImageForUpload(Uint8List.fromList(dirty), 'image/gif'),
|
||||
clean,
|
||||
);
|
||||
});
|
||||
|
||||
test('drops GIF applications with binary authentication codes', () {
|
||||
final clean = _minimalGif();
|
||||
final dirty = <int>[
|
||||
...clean.sublist(0, 19),
|
||||
..._gifApplicationExtensionBytes([
|
||||
...ascii.encode('FOREIGN1'),
|
||||
0xff,
|
||||
0x80,
|
||||
0x00,
|
||||
], utf8.encode('private')),
|
||||
...clean.sublist(19),
|
||||
];
|
||||
|
||||
expect(
|
||||
sanitizeAnimatedImageForUpload(Uint8List.fromList(dirty), 'image/gif'),
|
||||
clean,
|
||||
);
|
||||
});
|
||||
|
||||
test('removes a GIF graphic control consumed by stripped plain text', () {
|
||||
final clean = _minimalGif();
|
||||
final dirty = <int>[
|
||||
...clean.sublist(0, 19),
|
||||
0x21,
|
||||
0xf9,
|
||||
4,
|
||||
0x09,
|
||||
0x1e,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
..._gifPlainTextExtension(),
|
||||
...clean.sublist(19),
|
||||
];
|
||||
|
||||
expect(
|
||||
sanitizeAnimatedImageForUpload(Uint8List.fromList(dirty), 'image/gif'),
|
||||
clean,
|
||||
);
|
||||
});
|
||||
|
||||
test('keeps clean animated containers byte-identical', () {
|
||||
for (final (mimeType, bytes) in [
|
||||
('image/png', _animatedPng(metadata: false)),
|
||||
('image/webp', _animatedWebp(metadata: false)),
|
||||
('image/gif', _minimalGif()),
|
||||
]) {
|
||||
expect(
|
||||
sanitizeAnimatedImageForUpload(Uint8List.fromList(bytes), mimeType),
|
||||
bytes,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('fails closed for malformed animated containers', () {
|
||||
for (final (mimeType, bytes) in [
|
||||
('image/png', <int>[0x89, 0x50, 0x4e, 0x47]),
|
||||
('image/webp', ascii.encode('RIFFxxxxWEBP')),
|
||||
('image/gif', ascii.encode('GIF89a')),
|
||||
]) {
|
||||
expect(
|
||||
() =>
|
||||
sanitizeAnimatedImageForUpload(Uint8List.fromList(bytes), mimeType),
|
||||
throwsFormatException,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
List<int> _pngChunk(String type, List<int> payload) {
|
||||
return [
|
||||
..._uint32BigEndian(payload.length),
|
||||
...ascii.encode(type),
|
||||
...payload,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
];
|
||||
}
|
||||
|
||||
List<int> _animatedPng({
|
||||
required bool metadata,
|
||||
int? orientation,
|
||||
Endian orientationEndian = Endian.little,
|
||||
bool iccProfile = false,
|
||||
}) {
|
||||
return [
|
||||
0x89,
|
||||
0x50,
|
||||
0x4e,
|
||||
0x47,
|
||||
0x0d,
|
||||
0x0a,
|
||||
0x1a,
|
||||
0x0a,
|
||||
..._pngChunk('IHDR', List.filled(13, 0)),
|
||||
..._pngChunk('acTL', [0, 0, 0, 2, 0, 0, 0, 0]),
|
||||
if (iccProfile) ..._pngChunk('iCCP', utf8.encode('profile')),
|
||||
if (orientation != null)
|
||||
..._pngChunk(
|
||||
'eXIf',
|
||||
_exifOrientation(
|
||||
orientation,
|
||||
orientationEndian,
|
||||
includePreamble: false,
|
||||
),
|
||||
),
|
||||
if (metadata) ..._pngChunk('tEXt', utf8.encode('Location\u0000secret')),
|
||||
if (metadata) ..._pngChunk('pHYs', List.filled(9, 0)),
|
||||
..._pngChunk('fcTL', List.filled(26, 0)),
|
||||
..._pngChunk('IDAT', [1, 2, 3]),
|
||||
..._pngChunk('fdAT', [0, 0, 0, 1, 4, 5]),
|
||||
..._pngChunk('IEND', const []),
|
||||
];
|
||||
}
|
||||
|
||||
List<int> _webpChunk(String type, List<int> payload) {
|
||||
return [
|
||||
...ascii.encode(type),
|
||||
..._uint32LittleEndian(payload.length),
|
||||
...payload,
|
||||
if (payload.length.isOdd) 0,
|
||||
];
|
||||
}
|
||||
|
||||
List<int> _animatedWebp({
|
||||
required bool metadata,
|
||||
int? orientation,
|
||||
Endian orientationEndian = Endian.little,
|
||||
bool iccProfile = false,
|
||||
bool nestedMetadata = false,
|
||||
}) {
|
||||
final hasExif = metadata || orientation != null;
|
||||
final frame = <int>[
|
||||
...List.filled(16, 0),
|
||||
..._webpChunk('VP8 ', [1, 2, 3]),
|
||||
if (nestedMetadata) ..._webpChunk('JUNK', utf8.encode('location')),
|
||||
];
|
||||
final chunks = <int>[
|
||||
..._webpChunk('VP8X', [
|
||||
0x02 | (hasExif ? 0x0c : 0) | (iccProfile ? 0x20 : 0),
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
]),
|
||||
..._webpChunk('ANIM', List.filled(6, 0)),
|
||||
if (iccProfile) ..._webpChunk('ICCP', utf8.encode('profile')),
|
||||
if (orientation != null)
|
||||
..._webpChunk(
|
||||
'EXIF',
|
||||
_exifOrientation(orientation, orientationEndian, includePreamble: true),
|
||||
)
|
||||
else if (metadata)
|
||||
..._webpChunk('EXIF', utf8.encode('location')),
|
||||
if (metadata) ..._webpChunk('XMP ', utf8.encode('<xmp/>')),
|
||||
if (metadata) ..._webpChunk('JUNK', utf8.encode('private')),
|
||||
..._webpChunk('ANMF', frame),
|
||||
];
|
||||
return [
|
||||
...ascii.encode('RIFF'),
|
||||
..._uint32LittleEndian(chunks.length + 4),
|
||||
...ascii.encode('WEBP'),
|
||||
...chunks,
|
||||
];
|
||||
}
|
||||
|
||||
List<int> _exifOrientation(
|
||||
int orientation,
|
||||
Endian endian, {
|
||||
required bool includePreamble,
|
||||
}) {
|
||||
final bytes = BytesBuilder();
|
||||
if (includePreamble) {
|
||||
bytes
|
||||
..add(ascii.encode('Exif'))
|
||||
..add(const [0, 0]);
|
||||
}
|
||||
final tiff = ByteData(26);
|
||||
if (endian == Endian.little) {
|
||||
tiff.setUint8(0, 0x49);
|
||||
tiff.setUint8(1, 0x49);
|
||||
} else {
|
||||
tiff.setUint8(0, 0x4d);
|
||||
tiff.setUint8(1, 0x4d);
|
||||
}
|
||||
tiff.setUint16(2, 42, endian);
|
||||
tiff.setUint32(4, 8, endian);
|
||||
tiff.setUint16(8, 1, endian);
|
||||
tiff.setUint16(10, 0x0112, endian);
|
||||
tiff.setUint16(12, 3, endian);
|
||||
tiff.setUint32(14, 1, endian);
|
||||
tiff.setUint16(18, orientation, endian);
|
||||
tiff.setUint32(22, 0, endian);
|
||||
bytes.add(tiff.buffer.asUint8List());
|
||||
return bytes.takeBytes();
|
||||
}
|
||||
|
||||
List<int> _minimalGif() {
|
||||
return [
|
||||
...ascii.encode('GIF89a'),
|
||||
2,
|
||||
0,
|
||||
2,
|
||||
0,
|
||||
0x80,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0xff,
|
||||
0xff,
|
||||
0xff,
|
||||
..._gifLoopExtension(),
|
||||
0x21,
|
||||
0xf9,
|
||||
4,
|
||||
0,
|
||||
10,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0x2c,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
2,
|
||||
0,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
2,
|
||||
2,
|
||||
0x44,
|
||||
1,
|
||||
0,
|
||||
0x3b,
|
||||
];
|
||||
}
|
||||
|
||||
List<int> _gifLoopExtension({List<int> extra = const []}) {
|
||||
return [
|
||||
0x21,
|
||||
0xff,
|
||||
11,
|
||||
...ascii.encode('NETSCAPE2.0'),
|
||||
3,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
if (extra.isNotEmpty) ...[extra.length, ...extra],
|
||||
0,
|
||||
];
|
||||
}
|
||||
|
||||
List<int> _gifCommentExtension() {
|
||||
return [0x21, 0xfe, 5, ...ascii.encode('hello'), 0];
|
||||
}
|
||||
|
||||
List<int> _gifApplicationExtension(String identifier, List<int> payload) {
|
||||
return _gifApplicationExtensionBytes(ascii.encode(identifier), payload);
|
||||
}
|
||||
|
||||
List<int> _gifApplicationExtensionBytes(
|
||||
List<int> identifier,
|
||||
List<int> payload,
|
||||
) {
|
||||
return [0x21, 0xff, 11, ...identifier, payload.length, ...payload, 0];
|
||||
}
|
||||
|
||||
List<int> _gifPlainTextExtension() {
|
||||
return [0x21, 0x01, 12, 0, 0, 0, 0, 2, 0, 2, 0, 1, 1, 1, 0, 1, 0x78, 0];
|
||||
}
|
||||
|
||||
List<int> _uint32BigEndian(int value) {
|
||||
return [
|
||||
value >> 24 & 0xff,
|
||||
value >> 16 & 0xff,
|
||||
value >> 8 & 0xff,
|
||||
value & 0xff,
|
||||
];
|
||||
}
|
||||
|
||||
List<int> _uint32LittleEndian(int value) {
|
||||
return [
|
||||
value & 0xff,
|
||||
value >> 8 & 0xff,
|
||||
value >> 16 & 0xff,
|
||||
value >> 24 & 0xff,
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import 'package:buzz/shared/relay/identity_scoped_prefs.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
const canonical = 'k_v1:https://relay.example:pk';
|
||||
const legacy = 'k_v1:wss://relay.example:pk';
|
||||
|
||||
Future<SharedPreferences> prefsWith(Map<String, Object> values) async {
|
||||
SharedPreferences.setMockInitialValues(values);
|
||||
return SharedPreferences.getInstance();
|
||||
}
|
||||
|
||||
String? readString(SharedPreferences prefs) => readMigratedPref<String>(
|
||||
prefs,
|
||||
canonicalKey: canonical,
|
||||
legacyKey: legacy,
|
||||
read: prefs.getString,
|
||||
write: prefs.setString,
|
||||
);
|
||||
|
||||
test('returns the canonical value when present', () async {
|
||||
final prefs = await prefsWith({canonical: 'new', legacy: 'old'});
|
||||
expect(readString(prefs), 'new');
|
||||
});
|
||||
|
||||
test('falls back to the legacy value and returns it immediately', () async {
|
||||
final prefs = await prefsWith({legacy: 'carried over'});
|
||||
expect(readString(prefs), 'carried over');
|
||||
});
|
||||
|
||||
test('promotes the legacy value onto the canonical key', () async {
|
||||
final prefs = await prefsWith({legacy: 'carried over'});
|
||||
readString(prefs);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
expect(prefs.getString(canonical), 'carried over');
|
||||
expect(prefs.getString(legacy), isNull, reason: 'legacy entry is cleared');
|
||||
});
|
||||
|
||||
test('is idempotent across repeated reads', () async {
|
||||
final prefs = await prefsWith({legacy: 'carried over'});
|
||||
readString(prefs);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(readString(prefs), 'carried over');
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(prefs.getString(canonical), 'carried over');
|
||||
});
|
||||
|
||||
test('returns null when neither key holds a value', () async {
|
||||
final prefs = await prefsWith({});
|
||||
expect(readString(prefs), isNull);
|
||||
});
|
||||
|
||||
test('does not touch storage when the keys coincide', () async {
|
||||
// A pairing-created community already stores an HTTP origin, so canonical
|
||||
// and legacy are the same string and there is nothing to migrate.
|
||||
SharedPreferences.setMockInitialValues({canonical: 'only'});
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final value = readMigratedPref<String>(
|
||||
prefs,
|
||||
canonicalKey: canonical,
|
||||
legacyKey: canonical,
|
||||
read: prefs.getString,
|
||||
write: prefs.setString,
|
||||
);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
expect(value, 'only');
|
||||
expect(prefs.getString(canonical), 'only');
|
||||
});
|
||||
|
||||
test('migrates string lists as well as strings', () async {
|
||||
final prefs = await prefsWith({
|
||||
legacy: <String>['alpha', 'beta'],
|
||||
});
|
||||
final value = readMigratedPref<List<String>>(
|
||||
prefs,
|
||||
canonicalKey: canonical,
|
||||
legacyKey: legacy,
|
||||
read: prefs.getStringList,
|
||||
write: prefs.setStringList,
|
||||
);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
expect(value, ['alpha', 'beta']);
|
||||
expect(prefs.getStringList(canonical), ['alpha', 'beta']);
|
||||
expect(prefs.getStringList(legacy), isNull);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:buzz/shared/relay/media_auth.dart';
|
||||
import 'package:buzz/shared/relay/media_image.dart';
|
||||
import 'package:flutter/painting.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart' as http_testing;
|
||||
import 'package:nostr/nostr.dart' as nostr;
|
||||
|
||||
// Minimal valid 1x1 transparent PNG.
|
||||
final _pngBytes = base64Decode(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAA'
|
||||
'AAYAAjCB0C8AAAAASUVORK5CYII=',
|
||||
);
|
||||
|
||||
const _relayBase = 'https://relay.example.com';
|
||||
const _mediaUrl = '$_relayBase/media/abc123.png';
|
||||
|
||||
MediaGetAuthService _auth({String? nsec, DateTime Function()? now}) =>
|
||||
MediaGetAuthService(baseUrl: _relayBase, nsec: nsec, now: now);
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
setUp(() {
|
||||
MediaImageProvider.debugResetCooldowns();
|
||||
MediaImageProvider.debugNow = DateTime.now;
|
||||
PaintingBinding.instance.imageCache.clear();
|
||||
PaintingBinding.instance.imageCache.clearLiveImages();
|
||||
});
|
||||
|
||||
group('MediaGetAuthService memoization', () {
|
||||
test('repeated calls return byte-identical headers', () {
|
||||
final nsec = nostr.Keys.generate().nsec;
|
||||
final auth = _auth(nsec: nsec);
|
||||
final first = auth.headersFor(_mediaUrl);
|
||||
final second = auth.headersFor(_mediaUrl);
|
||||
expect(first, isNotEmpty);
|
||||
expect(identical(first, second), isTrue);
|
||||
});
|
||||
|
||||
test('re-signs only at the refresh margin before expiry', () {
|
||||
final nsec = nostr.Keys.generate().nsec;
|
||||
var current = DateTime.utc(2026, 7, 21, 12);
|
||||
final auth = _auth(nsec: nsec, now: () => current);
|
||||
|
||||
final first = auth.headersFor(_mediaUrl);
|
||||
// 600s lifetime - 60s margin = re-sign boundary at +540s.
|
||||
current = current.add(const Duration(seconds: 539));
|
||||
expect(identical(auth.headersFor(_mediaUrl), first), isTrue);
|
||||
|
||||
current = current.add(const Duration(seconds: 2));
|
||||
final refreshed = auth.headersFor(_mediaUrl);
|
||||
expect(identical(refreshed, first), isFalse);
|
||||
expect(refreshed['Authorization'], isNot(first['Authorization']));
|
||||
});
|
||||
|
||||
test('non-relay URLs get no headers even with a key', () {
|
||||
final nsec = nostr.Keys.generate().nsec;
|
||||
final auth = _auth(nsec: nsec);
|
||||
expect(auth.headersFor('https://elsewhere.com/media/abc.png'), isEmpty);
|
||||
expect(auth.headersFor('$_relayBase/not-media/abc.png'), isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
group('MediaImageProvider cache identity', () {
|
||||
test('equal url + same auth service => equal keys', () {
|
||||
final auth = _auth(nsec: nostr.Keys.generate().nsec);
|
||||
final client = http.Client();
|
||||
addTearDown(client.close);
|
||||
final a = MediaImageProvider(url: _mediaUrl, auth: auth, client: client);
|
||||
final b = MediaImageProvider(url: _mediaUrl, auth: auth, client: client);
|
||||
expect(a, equals(b));
|
||||
expect(a.hashCode, equals(b.hashCode));
|
||||
});
|
||||
|
||||
test('different auth service (relay/account switch) => unequal keys', () {
|
||||
final client = http.Client();
|
||||
addTearDown(client.close);
|
||||
final a = MediaImageProvider(
|
||||
url: _mediaUrl,
|
||||
auth: _auth(nsec: nostr.Keys.generate().nsec),
|
||||
client: client,
|
||||
);
|
||||
final b = MediaImageProvider(
|
||||
url: _mediaUrl,
|
||||
auth: _auth(nsec: nostr.Keys.generate().nsec),
|
||||
client: client,
|
||||
);
|
||||
expect(a, isNot(equals(b)));
|
||||
});
|
||||
|
||||
test('transport client is not part of identity', () {
|
||||
final auth = _auth(nsec: nostr.Keys.generate().nsec);
|
||||
final c1 = http.Client();
|
||||
final c2 = http.Client();
|
||||
addTearDown(c1.close);
|
||||
addTearDown(c2.close);
|
||||
expect(
|
||||
MediaImageProvider(url: _mediaUrl, auth: auth, client: c1),
|
||||
equals(MediaImageProvider(url: _mediaUrl, auth: auth, client: c2)),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('MediaImageProvider fetching', () {
|
||||
test('resolves one fetch for repeated resolves of the same key', () async {
|
||||
var fetches = 0;
|
||||
final client = http_testing.MockClient((request) async {
|
||||
fetches += 1;
|
||||
return http.Response.bytes(_pngBytes, 200);
|
||||
});
|
||||
final auth = _auth(nsec: nostr.Keys.generate().nsec);
|
||||
|
||||
for (var i = 0; i < 3; i++) {
|
||||
final provider = MediaImageProvider(
|
||||
url: _mediaUrl,
|
||||
auth: auth,
|
||||
client: client,
|
||||
);
|
||||
final completer = provider.resolve(ImageConfiguration.empty);
|
||||
await _wait(completer);
|
||||
}
|
||||
expect(fetches, 1);
|
||||
});
|
||||
|
||||
test('sends auth headers with the fetch', () async {
|
||||
Map<String, String>? seen;
|
||||
final client = http_testing.MockClient((request) async {
|
||||
seen = request.headers;
|
||||
return http.Response.bytes(_pngBytes, 200);
|
||||
});
|
||||
final auth = _auth(nsec: nostr.Keys.generate().nsec);
|
||||
final provider = MediaImageProvider(
|
||||
url: _mediaUrl,
|
||||
auth: auth,
|
||||
client: client,
|
||||
);
|
||||
await _wait(provider.resolve(ImageConfiguration.empty));
|
||||
expect(seen?['Authorization'], startsWith('Nostr '));
|
||||
});
|
||||
|
||||
test('failed URL is not refetched until cooldown elapses', () async {
|
||||
var fetches = 0;
|
||||
final client = http_testing.MockClient((request) async {
|
||||
fetches += 1;
|
||||
return http.Response('rate limited', 429);
|
||||
});
|
||||
final auth = _auth(nsec: nostr.Keys.generate().nsec);
|
||||
var current = DateTime.utc(2026, 7, 21, 12);
|
||||
MediaImageProvider.debugNow = () => current;
|
||||
|
||||
Future<Object?> attempt() async {
|
||||
final provider = MediaImageProvider(
|
||||
url: _mediaUrl,
|
||||
auth: auth,
|
||||
client: client,
|
||||
);
|
||||
return _waitError(provider.resolve(ImageConfiguration.empty));
|
||||
}
|
||||
|
||||
expect(await attempt(), isA<NetworkImageLoadException>());
|
||||
expect(fetches, 1);
|
||||
|
||||
// Within cooldown: suppressed, no network call.
|
||||
expect(await attempt(), isA<MediaImageCooldownException>());
|
||||
expect(fetches, 1);
|
||||
|
||||
// After cooldown: retried.
|
||||
current = current.add(const Duration(seconds: 31));
|
||||
expect(await attempt(), isA<NetworkImageLoadException>());
|
||||
expect(fetches, 2);
|
||||
});
|
||||
|
||||
test('honors Retry-After on 429 (capped)', () async {
|
||||
var fetches = 0;
|
||||
final client = http_testing.MockClient((request) async {
|
||||
fetches += 1;
|
||||
return http.Response(
|
||||
'rate limited',
|
||||
429,
|
||||
headers: {'retry-after': '120'},
|
||||
);
|
||||
});
|
||||
final auth = _auth(nsec: nostr.Keys.generate().nsec);
|
||||
var current = DateTime.utc(2026, 7, 21, 12);
|
||||
MediaImageProvider.debugNow = () => current;
|
||||
|
||||
Future<Object?> attempt() async {
|
||||
final provider = MediaImageProvider(
|
||||
url: _mediaUrl,
|
||||
auth: auth,
|
||||
client: client,
|
||||
);
|
||||
return _waitError(provider.resolve(ImageConfiguration.empty));
|
||||
}
|
||||
|
||||
await attempt();
|
||||
expect(fetches, 1);
|
||||
|
||||
current = current.add(const Duration(seconds: 60));
|
||||
expect(await attempt(), isA<MediaImageCooldownException>());
|
||||
expect(fetches, 1);
|
||||
|
||||
current = current.add(const Duration(seconds: 61));
|
||||
await attempt();
|
||||
expect(fetches, 2);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _wait(ImageStream stream) {
|
||||
final done = Completer<void>();
|
||||
late final ImageStreamListener listener;
|
||||
listener = ImageStreamListener(
|
||||
(image, sync) {
|
||||
image.dispose();
|
||||
stream.removeListener(listener);
|
||||
if (!done.isCompleted) done.complete();
|
||||
},
|
||||
onError: (error, stack) {
|
||||
stream.removeListener(listener);
|
||||
if (!done.isCompleted) done.completeError(error, stack);
|
||||
},
|
||||
);
|
||||
stream.addListener(listener);
|
||||
return done.future;
|
||||
}
|
||||
|
||||
Future<Object?> _waitError(ImageStream stream) async {
|
||||
try {
|
||||
await _wait(stream);
|
||||
return null;
|
||||
} catch (e) {
|
||||
return e;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,145 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:buzz/shared/relay/mp4_fast_start.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
late Directory tempDirectory;
|
||||
|
||||
setUp(() async {
|
||||
tempDirectory = await Directory.systemTemp.createTemp(
|
||||
'buzz-faststart-test-',
|
||||
);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await tempDirectory.delete(recursive: true);
|
||||
});
|
||||
|
||||
for (final offsetBox in ['stco', 'co64']) {
|
||||
test('moves moov before mdat and patches $offsetBox offsets', () async {
|
||||
final ftyp = _box('ftyp', [
|
||||
...ascii.encode('isom'),
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
...ascii.encode('isom'),
|
||||
]);
|
||||
final mdat = _box('mdat', [1, 2, 3, 4]);
|
||||
final originalMediaOffset = ftyp.length + 8;
|
||||
final offsetPayload = BytesBuilder()
|
||||
..add([0, 0, 0, 0])
|
||||
..add(_uint32(1))
|
||||
..add(
|
||||
offsetBox == 'stco'
|
||||
? _uint32(originalMediaOffset)
|
||||
: _uint64(originalMediaOffset),
|
||||
);
|
||||
final sampleTable = _box(offsetBox, offsetPayload.takeBytes());
|
||||
final moov = _nestedMoov(sampleTable);
|
||||
final sourceBytes = Uint8List.fromList([...ftyp, ...mdat, ...moov]);
|
||||
final source = File('${tempDirectory.path}/source.mp4');
|
||||
final destination = File('${tempDirectory.path}/output.mp4');
|
||||
await source.writeAsBytes(sourceBytes);
|
||||
|
||||
await rewriteMp4ForFastStart(source, destination);
|
||||
|
||||
final output = await destination.readAsBytes();
|
||||
expect(_topLevelTypes(output), ['ftyp', 'moov', 'mdat']);
|
||||
expect(
|
||||
output.sublist(ftyp.length + moov.length + 8),
|
||||
equals([1, 2, 3, 4]),
|
||||
);
|
||||
|
||||
final typeOffset = _findAscii(output, offsetBox);
|
||||
expect(typeOffset, greaterThanOrEqualTo(4));
|
||||
final entryOffset = typeOffset + 12;
|
||||
final adjusted = offsetBox == 'stco'
|
||||
? _readUint32(output, entryOffset)
|
||||
: _readUint64(output, entryOffset);
|
||||
expect(adjusted, originalMediaOffset + moov.length);
|
||||
});
|
||||
}
|
||||
|
||||
test(
|
||||
'rejects excessive nested box depth and deletes partial output',
|
||||
() async {
|
||||
final ftyp = _box('ftyp', [
|
||||
...ascii.encode('isom'),
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
...ascii.encode('isom'),
|
||||
]);
|
||||
final mdat = _box('mdat', [1]);
|
||||
var nested = _box('stco', [0, 0, 0, 0, ..._uint32(0)]);
|
||||
for (var index = 0; index < 34; index++) {
|
||||
nested = _box('stbl', nested);
|
||||
}
|
||||
final source = File('${tempDirectory.path}/deep.mp4');
|
||||
final destination = File('${tempDirectory.path}/output.mp4');
|
||||
await source.writeAsBytes([...ftyp, ...mdat, ..._box('moov', nested)]);
|
||||
|
||||
await expectLater(
|
||||
rewriteMp4ForFastStart(source, destination),
|
||||
throwsA(isA<FormatException>()),
|
||||
);
|
||||
expect(await destination.exists(), isFalse);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Uint8List _nestedMoov(List<int> leaf) =>
|
||||
_box('moov', _box('trak', _box('mdia', _box('minf', _box('stbl', leaf)))));
|
||||
|
||||
Uint8List _box(String type, List<int> payload) => Uint8List.fromList([
|
||||
..._uint32(payload.length + 8),
|
||||
...ascii.encode(type),
|
||||
...payload,
|
||||
]);
|
||||
|
||||
Uint8List _uint32(int value) {
|
||||
final bytes = ByteData(4)..setUint32(0, value, Endian.big);
|
||||
return bytes.buffer.asUint8List();
|
||||
}
|
||||
|
||||
Uint8List _uint64(int value) {
|
||||
final bytes = ByteData(8)..setUint64(0, value, Endian.big);
|
||||
return bytes.buffer.asUint8List();
|
||||
}
|
||||
|
||||
int _readUint32(Uint8List bytes, int offset) =>
|
||||
ByteData.sublistView(bytes).getUint32(offset, Endian.big);
|
||||
|
||||
int _readUint64(Uint8List bytes, int offset) =>
|
||||
ByteData.sublistView(bytes).getUint64(offset, Endian.big);
|
||||
|
||||
List<String> _topLevelTypes(Uint8List bytes) {
|
||||
final types = <String>[];
|
||||
var offset = 0;
|
||||
while (offset < bytes.length) {
|
||||
final size = _readUint32(bytes, offset);
|
||||
types.add(ascii.decode(bytes.sublist(offset + 4, offset + 8)));
|
||||
offset += size;
|
||||
}
|
||||
return types;
|
||||
}
|
||||
|
||||
int _findAscii(Uint8List bytes, String value) {
|
||||
final needle = ascii.encode(value);
|
||||
for (var offset = 0; offset + needle.length <= bytes.length; offset++) {
|
||||
var matches = true;
|
||||
for (var index = 0; index < needle.length; index++) {
|
||||
if (bytes[offset + index] != needle[index]) {
|
||||
matches = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (matches) return offset;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:buzz/shared/relay/nostr_models.dart';
|
||||
|
||||
void main() {
|
||||
test('NostrFilter serializes and preserves authors', () {
|
||||
const filter = NostrFilter(
|
||||
kinds: [EventKind.readState],
|
||||
authors: ['pubkey-a'],
|
||||
tags: {
|
||||
'#t': ['read-state'],
|
||||
},
|
||||
since: 10,
|
||||
limit: 5,
|
||||
);
|
||||
|
||||
expect(filter.toJson(), {
|
||||
'kinds': [EventKind.readState],
|
||||
'limit': 5,
|
||||
'authors': ['pubkey-a'],
|
||||
'since': 10,
|
||||
'#t': ['read-state'],
|
||||
});
|
||||
|
||||
final copied = filter.copyWithSince(20);
|
||||
expect(copied.toJson(), {
|
||||
'kinds': [EventKind.readState],
|
||||
'limit': 5,
|
||||
'authors': ['pubkey-a'],
|
||||
'since': 20,
|
||||
'#t': ['read-state'],
|
||||
});
|
||||
});
|
||||
|
||||
test('NostrFilter can serialize broad deletion subscriptions', () {
|
||||
const filter = NostrFilter(kinds: [EventKind.deletion], limit: 0);
|
||||
|
||||
expect(filter.toJson(), {
|
||||
'kinds': [EventKind.deletion],
|
||||
'limit': 0,
|
||||
});
|
||||
});
|
||||
|
||||
test('NostrFilter serializes relay query extensions', () {
|
||||
const filter = NostrFilter(
|
||||
kinds: EventKind.channelTimelineContentKinds,
|
||||
tags: {
|
||||
'#h': ['channel-id'],
|
||||
},
|
||||
limit: 50,
|
||||
extensions: {
|
||||
'top_level': true,
|
||||
'include_summaries': true,
|
||||
'include_aux': true,
|
||||
'before_id': 'cursor-id',
|
||||
},
|
||||
);
|
||||
|
||||
expect(filter.toJson(), {
|
||||
'kinds': EventKind.channelTimelineContentKinds,
|
||||
'limit': 50,
|
||||
'#h': ['channel-id'],
|
||||
'top_level': true,
|
||||
'include_summaries': true,
|
||||
'include_aux': true,
|
||||
'before_id': 'cursor-id',
|
||||
});
|
||||
});
|
||||
|
||||
test('channel unread activity kinds exclude non-message updates', () {
|
||||
expect(
|
||||
EventKind.channelMessageEventKinds,
|
||||
contains(EventKind.streamMessage),
|
||||
);
|
||||
expect(EventKind.channelMessageEventKinds, contains(EventKind.forumPost));
|
||||
expect(
|
||||
EventKind.channelMessageEventKinds,
|
||||
contains(EventKind.forumComment),
|
||||
);
|
||||
|
||||
expect(
|
||||
EventKind.channelMessageEventKinds,
|
||||
isNot(contains(EventKind.reaction)),
|
||||
);
|
||||
expect(
|
||||
EventKind.channelMessageEventKinds,
|
||||
isNot(contains(EventKind.streamMessageEdit)),
|
||||
);
|
||||
expect(
|
||||
EventKind.channelMessageEventKinds,
|
||||
isNot(contains(EventKind.streamMessageDiff)),
|
||||
);
|
||||
expect(
|
||||
EventKind.channelMessageEventKinds,
|
||||
isNot(contains(EventKind.deletion)),
|
||||
);
|
||||
expect(
|
||||
EventKind.channelMessageEventKinds,
|
||||
isNot(contains(EventKind.systemMessage)),
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import 'package:buzz/shared/relay/relay.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
group('classifyRelayClosed', () {
|
||||
const cases = {
|
||||
'rate-limited: quota exceeded; retry in 4s': RelayClosedClass.rateLimited,
|
||||
'rate-limited: too many concurrent requests':
|
||||
RelayClosedClass.rateLimited,
|
||||
'restricted: access revoked': RelayClosedClass.terminal,
|
||||
'auth-required: verification failed': RelayClosedClass.terminal,
|
||||
'blocked: community policy': RelayClosedClass.terminal,
|
||||
'invalid: malformed filter': RelayClosedClass.terminal,
|
||||
'pow: insufficient work': RelayClosedClass.terminal,
|
||||
'duplicate: subscription already exists': RelayClosedClass.terminal,
|
||||
'unsupported: filter extension': RelayClosedClass.terminal,
|
||||
'error: mixed search and channel filter': RelayClosedClass.terminal,
|
||||
'error: too many subscriptions': RelayClosedClass.terminal,
|
||||
'error: relay temporarily unavailable': RelayClosedClass.retryable,
|
||||
'subscription closed by relay': RelayClosedClass.retryable,
|
||||
};
|
||||
|
||||
for (final entry in cases.entries) {
|
||||
test(entry.key, () {
|
||||
expect(classifyRelayClosed(entry.key), entry.value);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('parseRateLimitRetrySeconds handles canonical and absent hints', () {
|
||||
expect(
|
||||
parseRateLimitRetrySeconds('rate-limited: quota exceeded; retry in 17s'),
|
||||
17,
|
||||
);
|
||||
expect(parseRateLimitRetrySeconds('RETRY IN 2S'), 2);
|
||||
expect(parseRateLimitRetrySeconds('retry in 0s'), 0);
|
||||
expect(parseRateLimitRetrySeconds('retry in 999999s'), 999999);
|
||||
final oversizedHint = List.filled(1000, '9').join();
|
||||
expect(parseRateLimitRetrySeconds('retry in ${oversizedHint}s'), isNull);
|
||||
expect(
|
||||
parseRateLimitRetrySeconds('rate-limited: too many concurrent requests'),
|
||||
isNull,
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:buzz/shared/relay/relay_provider.dart';
|
||||
|
||||
void main() {
|
||||
group('RelayConfig.baseUrl normalization', () {
|
||||
test('folds a wss:// community URL to https://', () {
|
||||
// Invite joins persist the relay URL straight off the invite link, which
|
||||
// deep_link.dart always emits as ws:// or wss://.
|
||||
final config = RelayConfig(baseUrl: 'wss://relay.example.com');
|
||||
expect(config.baseUrl, 'https://relay.example.com');
|
||||
});
|
||||
|
||||
test('folds a ws:// community URL to http://', () {
|
||||
final config = RelayConfig(baseUrl: 'ws://relay.example.com:3000');
|
||||
expect(config.baseUrl, 'http://relay.example.com:3000');
|
||||
});
|
||||
|
||||
test('leaves an https:// community URL untouched', () {
|
||||
// Device pairing rejects anything but https://, so these already conform.
|
||||
final config = RelayConfig(baseUrl: 'https://relay.example.com');
|
||||
expect(config.baseUrl, 'https://relay.example.com');
|
||||
});
|
||||
|
||||
test('leaves an http:// community URL untouched', () {
|
||||
final config = RelayConfig(baseUrl: 'http://localhost:3000');
|
||||
expect(config.baseUrl, 'http://localhost:3000');
|
||||
});
|
||||
|
||||
test('preserves a non-default port', () {
|
||||
final config = RelayConfig(baseUrl: 'wss://relay.example.com:8443');
|
||||
expect(config.baseUrl, 'https://relay.example.com:8443');
|
||||
});
|
||||
});
|
||||
|
||||
group('RelayConfig.wsUrl', () {
|
||||
test('keeps TLS for a relay joined by invite', () {
|
||||
// Regression: a wss:// base used to fall through to the non-https branch
|
||||
// and downgrade to ws://, dialing port 80 — which never connects on a
|
||||
// relay that only serves 443, and drops TLS everywhere else.
|
||||
final config = RelayConfig(baseUrl: 'wss://relay.example.com');
|
||||
expect(config.wsUrl, 'wss://relay.example.com');
|
||||
});
|
||||
|
||||
test('keeps TLS for a relay added by pairing', () {
|
||||
final config = RelayConfig(baseUrl: 'https://relay.example.com');
|
||||
expect(config.wsUrl, 'wss://relay.example.com');
|
||||
});
|
||||
|
||||
test('both onboarding paths agree on the same relay', () {
|
||||
final invited = RelayConfig(baseUrl: 'wss://relay.example.com');
|
||||
final paired = RelayConfig(baseUrl: 'https://relay.example.com');
|
||||
expect(invited.wsUrl, paired.wsUrl);
|
||||
expect(invited.baseUrl, paired.baseUrl);
|
||||
});
|
||||
|
||||
test('stays plaintext for local development', () {
|
||||
final config = RelayConfig(baseUrl: 'http://localhost:3000');
|
||||
expect(config.wsUrl, 'ws://localhost:3000');
|
||||
});
|
||||
|
||||
test('preserves a non-default port', () {
|
||||
final config = RelayConfig(baseUrl: 'wss://relay.example.com:8443');
|
||||
expect(config.wsUrl, 'wss://relay.example.com:8443');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:buzz/shared/relay/relay.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
test('uses the default for absent and non-positive hints', () {
|
||||
var now = DateTime.utc(2026);
|
||||
final timers = <_ManualTimer>[];
|
||||
final gate = RelayRateLimitGate(
|
||||
now: () => now,
|
||||
timerFactory: (duration, callback) {
|
||||
final timer = _ManualTimer(duration, callback);
|
||||
timers.add(timer);
|
||||
return timer;
|
||||
},
|
||||
);
|
||||
|
||||
gate.activate(null);
|
||||
expect(gate.remainingMs(), 10000);
|
||||
expect(timers.single.duration, const Duration(seconds: 10));
|
||||
|
||||
now = now.add(const Duration(seconds: 11));
|
||||
gate.activate(0);
|
||||
expect(timers.last.duration, const Duration(seconds: 10));
|
||||
});
|
||||
|
||||
test('clamps large hints to five minutes', () {
|
||||
final timers = <_ManualTimer>[];
|
||||
final gate = RelayRateLimitGate(
|
||||
now: () => DateTime.utc(2026),
|
||||
timerFactory: (duration, callback) {
|
||||
final timer = _ManualTimer(duration, callback);
|
||||
timers.add(timer);
|
||||
return timer;
|
||||
},
|
||||
);
|
||||
|
||||
gate.activate(999999);
|
||||
|
||||
expect(timers.single.duration, const Duration(seconds: 300));
|
||||
expect(gate.remainingMs(), 300000);
|
||||
});
|
||||
|
||||
test('overlapping activations only extend the active window', () async {
|
||||
var now = DateTime.utc(2026);
|
||||
final timers = <_ManualTimer>[];
|
||||
final gate = RelayRateLimitGate(
|
||||
now: () => now,
|
||||
timerFactory: (duration, callback) {
|
||||
final timer = _ManualTimer(duration, callback);
|
||||
timers.add(timer);
|
||||
return timer;
|
||||
},
|
||||
);
|
||||
|
||||
gate.activate(30);
|
||||
final wait = gate.wait();
|
||||
final firstTimer = timers.single;
|
||||
|
||||
now = now.add(const Duration(seconds: 5));
|
||||
gate.activate(10);
|
||||
expect(timers, hasLength(1));
|
||||
expect(gate.remainingMs(), 25000);
|
||||
|
||||
gate.activate(40);
|
||||
expect(timers, hasLength(2));
|
||||
expect(firstTimer.isActive, isFalse);
|
||||
expect(gate.remainingMs(), 40000);
|
||||
|
||||
timers.last.fire();
|
||||
await wait;
|
||||
expect(gate.isActive, isFalse);
|
||||
expect(gate.remainingMs(), 0);
|
||||
});
|
||||
|
||||
test('reset releases waiters and cancels the active timer', () async {
|
||||
final timers = <_ManualTimer>[];
|
||||
final gate = RelayRateLimitGate(
|
||||
timerFactory: (duration, callback) {
|
||||
final timer = _ManualTimer(duration, callback);
|
||||
timers.add(timer);
|
||||
return timer;
|
||||
},
|
||||
);
|
||||
|
||||
gate.activate(20);
|
||||
final wait = gate.wait();
|
||||
gate.reset();
|
||||
|
||||
await wait;
|
||||
expect(timers.single.isActive, isFalse);
|
||||
expect(gate.isActive, isFalse);
|
||||
});
|
||||
}
|
||||
|
||||
class _ManualTimer implements Timer {
|
||||
_ManualTimer(this.duration, this._callback);
|
||||
|
||||
final Duration duration;
|
||||
final void Function() _callback;
|
||||
bool _active = true;
|
||||
|
||||
void fire() {
|
||||
if (!_active) return;
|
||||
_active = false;
|
||||
_callback();
|
||||
}
|
||||
|
||||
@override
|
||||
void cancel() => _active = false;
|
||||
|
||||
@override
|
||||
bool get isActive => _active;
|
||||
|
||||
@override
|
||||
int get tick => _active ? 0 : 1;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,111 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:buzz/shared/relay/relay_socket.dart';
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
/// A server that completes the WS handshake then never speaks again: no pongs,
|
||||
/// no close frame. Only a client-side ping timeout can notice.
|
||||
Future<ServerSocket> _silentAfterHandshakeServer() async {
|
||||
final server = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0);
|
||||
server.listen((client) {
|
||||
client.listen(
|
||||
(data) {
|
||||
final match = RegExp(
|
||||
r'Sec-WebSocket-Key: (.*)\r\n',
|
||||
caseSensitive: false,
|
||||
).firstMatch(String.fromCharCodes(data));
|
||||
if (match == null) return;
|
||||
final accept = base64.encode(
|
||||
sha1
|
||||
.convert(
|
||||
utf8.encode(
|
||||
'${match.group(1)!.trim()}258EAFA5-E914-47DA-95CA-C5AB0DC85B11',
|
||||
),
|
||||
)
|
||||
.bytes,
|
||||
);
|
||||
client.write(
|
||||
'HTTP/1.1 101 Switching Protocols\r\n'
|
||||
'Upgrade: websocket\r\nConnection: Upgrade\r\n'
|
||||
'Sec-WebSocket-Accept: $accept\r\n\r\n',
|
||||
);
|
||||
},
|
||||
onError: (_) {},
|
||||
onDone: () {},
|
||||
);
|
||||
});
|
||||
return server;
|
||||
}
|
||||
|
||||
void main() {
|
||||
const testPingInterval = Duration(milliseconds: 150);
|
||||
|
||||
setUp(() {
|
||||
RelaySocket.debugPingInterval = testPingInterval;
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
RelaySocket.debugPingInterval = RelaySocket.pingInterval;
|
||||
});
|
||||
|
||||
test('detects a peer that stops answering pings', () async {
|
||||
final server = await _silentAfterHandshakeServer();
|
||||
|
||||
final disconnected = Completer<Object?>();
|
||||
final socket = RelaySocket(
|
||||
wsUrl: 'ws://127.0.0.1:${server.port}',
|
||||
nsec: null,
|
||||
onMessage: (_) {},
|
||||
onConnected: () {},
|
||||
onDisconnected: (error) {
|
||||
if (!disconnected.isCompleted) disconnected.complete(error);
|
||||
},
|
||||
);
|
||||
unawaited(socket.connect());
|
||||
|
||||
var detected = true;
|
||||
try {
|
||||
await disconnected.future.timeout(testPingInterval * 4);
|
||||
} on TimeoutException {
|
||||
detected = false;
|
||||
}
|
||||
|
||||
expect(
|
||||
detected,
|
||||
isTrue,
|
||||
reason:
|
||||
'RelaySocket must surface an unanswered ping through onDisconnected',
|
||||
);
|
||||
|
||||
socket.dispose();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test('keeps an idle but healthy peer connected', () async {
|
||||
final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
|
||||
server.transform(WebSocketTransformer()).listen((ws) {
|
||||
// A healthy relay answers pings without sending application data.
|
||||
ws.listen((_) {}, onError: (_) {}, onDone: () {});
|
||||
});
|
||||
|
||||
var tornDown = false;
|
||||
final socket = RelaySocket(
|
||||
wsUrl: 'ws://127.0.0.1:${server.port}',
|
||||
nsec: null,
|
||||
onMessage: (_) {},
|
||||
onConnected: () {},
|
||||
onDisconnected: (_) => tornDown = true,
|
||||
);
|
||||
unawaited(socket.connect());
|
||||
|
||||
await Future<void>.delayed(testPingInterval * 4);
|
||||
|
||||
expect(tornDown, isFalse);
|
||||
|
||||
await socket.disconnect();
|
||||
await server.close(force: true);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:buzz/shared/relay/relay_validation.dart';
|
||||
|
||||
void main() {
|
||||
group('validateInviteRelayUri', () {
|
||||
test('accepts public secure relay origins', () {
|
||||
for (final url in [
|
||||
'wss://relay.example.com',
|
||||
'wss://relay.example.com/',
|
||||
'wss://relay.example.com:8443',
|
||||
'wss://8.8.8.8',
|
||||
'wss://[2001:4860:4860::8888]',
|
||||
'wss://[2001:1::1]',
|
||||
'wss://[2001:1::2]',
|
||||
'wss://[2001:1::3]',
|
||||
'wss://[2001:3::1]',
|
||||
'wss://[2001:4:112::1]',
|
||||
'wss://[2001:20::1]',
|
||||
'wss://[2001:30::1]',
|
||||
'wss://[2001:200::1]',
|
||||
'wss://[2620:4f:8000::1]',
|
||||
'wss://[3fff:1000::1]',
|
||||
'wss://[2606:4700:4700::1111]',
|
||||
]) {
|
||||
expect(
|
||||
() => validateInviteRelayUri(
|
||||
Uri.parse(url),
|
||||
allowInsecureLocalhost: false,
|
||||
),
|
||||
returnsNormally,
|
||||
reason: url,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('allows plaintext localhost only when explicitly enabled', () {
|
||||
for (final url in ['ws://localhost:3000', 'ws://relay.localhost:3000']) {
|
||||
expect(
|
||||
() => validateInviteRelayUri(
|
||||
Uri.parse(url),
|
||||
allowInsecureLocalhost: true,
|
||||
),
|
||||
returnsNormally,
|
||||
reason: url,
|
||||
);
|
||||
expect(
|
||||
() => validateInviteRelayUri(
|
||||
Uri.parse(url),
|
||||
allowInsecureLocalhost: false,
|
||||
),
|
||||
throwsFormatException,
|
||||
reason: url,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects plaintext public relays', () {
|
||||
expect(
|
||||
() => validateInviteRelayUri(
|
||||
Uri.parse('ws://relay.example.com'),
|
||||
allowInsecureLocalhost: false,
|
||||
),
|
||||
throwsFormatException,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects non-public IPv4 literals', () {
|
||||
for (final host in [
|
||||
'0.0.0.0',
|
||||
'10.0.0.1',
|
||||
'100.64.0.1',
|
||||
'127.0.0.1',
|
||||
'169.254.169.254',
|
||||
'172.16.0.1',
|
||||
'192.168.0.1',
|
||||
'198.18.0.1',
|
||||
'224.0.0.1',
|
||||
]) {
|
||||
expect(
|
||||
() => validateInviteRelayUri(
|
||||
Uri.parse('wss://$host'),
|
||||
allowInsecureLocalhost: false,
|
||||
),
|
||||
throwsFormatException,
|
||||
reason: host,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects non-public IPv6 literals and mapped IPv4', () {
|
||||
for (final host in [
|
||||
'[::]',
|
||||
'[::1]',
|
||||
'[::ffff:127.0.0.1]',
|
||||
'[::ffff:169.254.169.254]',
|
||||
'[::ffff:a9fe:a9fe]',
|
||||
'[64:ff9b::a9fe:a9fe]',
|
||||
'[100::1]',
|
||||
'[100:0:0:1::1]',
|
||||
'[2001::1]',
|
||||
'[2001:1::4]',
|
||||
'[2001:2::1]',
|
||||
'[2001:4:111::1]',
|
||||
'[2001:10::1]',
|
||||
'[2001:40::1]',
|
||||
'[2001:db8::1]',
|
||||
'[2002:a9fe:a9fe::1]',
|
||||
'[3fff::1]',
|
||||
'[3fff:fff::1]',
|
||||
'[5f00::1]',
|
||||
'[fc00::1]',
|
||||
'[fd00::1]',
|
||||
'[fe80::1]',
|
||||
'[fec0::1]',
|
||||
'[ff02::1]',
|
||||
]) {
|
||||
expect(
|
||||
() => validateInviteRelayUri(
|
||||
Uri.parse('wss://$host'),
|
||||
allowInsecureLocalhost: false,
|
||||
),
|
||||
throwsFormatException,
|
||||
reason: host,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects legacy numeric IPv4 spellings', () {
|
||||
for (final host in ['2130706433', '0x7f000001', '0177.0.0.1', '127.1']) {
|
||||
expect(
|
||||
() => validateInviteRelayUri(
|
||||
Uri.parse('wss://$host'),
|
||||
allowInsecureLocalhost: false,
|
||||
),
|
||||
throwsFormatException,
|
||||
reason: host,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects non-origin URLs', () {
|
||||
for (final url in [
|
||||
'https://relay.example.com',
|
||||
'wss://user@relay.example.com',
|
||||
'wss://relay.example.com/path',
|
||||
'wss://relay.example.com?query=x',
|
||||
'wss://relay.example.com#fragment',
|
||||
]) {
|
||||
expect(
|
||||
() => validateInviteRelayUri(
|
||||
Uri.parse(url),
|
||||
allowInsecureLocalhost: false,
|
||||
),
|
||||
throwsFormatException,
|
||||
reason: url,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user