Fix tag directory sorting and follow Client-Integrity.

Paginate tag (and ASC) lists via GQL with integrity retry; mint follow
integrity from a WebView because Dio /integrity tokens are bad_bot.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
copilot 2026-10-07 07:32:16 +02:00
parent 383e786479
commit 3fb393166f
13 changed files with 1197 additions and 207 deletions

View file

@ -1,3 +1,5 @@
import 'dart:convert';
import 'package:dio/dio.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:frosty/apis/base_api_client.dart';
@ -69,4 +71,32 @@ void main() {
await api.unfollowUser(targetId: '999', authToken: 'web-token');
});
});
group('fetchIntegrityToken', () {
test('rejects bad_bot tokens', () async {
final payload = base64Url.encode(
utf8.encode(
jsonEncode({
'is_bad_bot': 'true',
'device_id': 'dev',
'client_id': 'kimne78kx3ncx6brgo4mv6wki5h1ko',
}),
),
);
dioAdapter.onPost(
'https://gql.twitch.tv/integrity',
(server) => server.reply(200, {
'token': 'v4.public.$payload.sig',
'expiration': DateTime.now().millisecondsSinceEpoch + 600000,
}),
data: Matchers.any,
);
await expectLater(
api.fetchIntegrityToken(authToken: 'web', deviceId: 'dev'),
throwsA(isA<IntegrityCheckException>()),
);
});
});
}

View file

@ -0,0 +1,129 @@
import 'package:dio/dio.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:frosty/apis/base_api_client.dart';
import 'package:frosty/apis/twitch_gql_api.dart';
import 'package:http_mock_adapter/http_mock_adapter.dart';
void main() {
late Dio dio;
late DioAdapter dioAdapter;
late TwitchGqlApi api;
setUp(() {
dio = Dio(BaseOptions());
dioAdapter = DioAdapter(dio: dio);
api = TwitchGqlApi(dio);
});
Map<String, Object?> streamEdge({
required String id,
required String login,
required int viewers,
String? cursor,
}) {
return {
'cursor': cursor ?? 'cursor-$id',
'node': {
'id': id,
'title': '$login live',
'viewersCount': viewers,
'createdAt': '2026-01-01T00:00:00Z',
'language': 'en',
'game': {'id': '1', 'name': 'Game'},
'broadcaster': {
'id': id,
'login': login,
'displayName': login,
},
},
};
}
group('getStreams', () {
test('parses freeform tag page and cursor', () async {
dioAdapter.onPost(
'https://gql.twitch.tv/gql',
(server) => server.reply(200, {
'data': {
'streams': {
'edges': [
streamEdge(id: '1', login: 'a', viewers: 10),
streamEdge(id: '2', login: 'b', viewers: 5),
],
'pageInfo': {'hasNextPage': true},
},
},
}),
data: Matchers.any,
);
final page = await api.getStreams(
freeformTags: ['Deutsch'],
sort: StreamViewerSort.viewersAsc,
);
expect(page.data.map((s) => s.userLogin), ['a', 'b']);
expect(page.data.map((s) => s.viewerCount), [10, 5]);
expect(page.pagination['cursor'], 'cursor-2');
});
test('throws IntegrityCheckException on integrity errors', () async {
dioAdapter.onPost(
'https://gql.twitch.tv/gql',
(server) => server.reply(200, {
'errors': [
{'message': 'failed integrity check'},
],
'data': {'streams': null},
}),
data: Matchers.any,
);
await expectLater(
api.getStreams(
freeformTags: ['Deutsch'],
cursor: 'page-2',
authToken: 'web',
integrityToken: 'stale',
deviceId: 'device',
),
throwsA(isA<IntegrityCheckException>()),
);
});
test('sends Client-Integrity and X-Device-Id when provided', () async {
dioAdapter.onPost(
'https://gql.twitch.tv/gql',
(server) => server.reply(200, {
'data': {
'streams': {
'edges': [streamEdge(id: '1', login: 'a', viewers: 1)],
'pageInfo': {'hasNextPage': false},
},
},
}),
data: Matchers.any,
headers: {
'Client-ID': Matchers.any,
'Content-Type': Matchers.any,
'User-Agent': Matchers.any,
'Authorization': 'OAuth web-token',
'X-Device-Id': 'dev-123',
'Client-Integrity': 'integrity-token',
},
);
final page = await api.getStreams(
freeformTags: ['English'],
sort: StreamViewerSort.viewersDesc,
cursor: 'after-1',
authToken: 'web-token',
integrityToken: 'integrity-token',
deviceId: 'dev-123',
);
expect(page.data, hasLength(1));
expect(page.pagination, isEmpty);
});
});
}

View file

@ -0,0 +1,42 @@
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:frosty/apis/twitch_integrity.dart';
String _tokenFor(Map<String, Object?> payload) {
final encoded = base64Url.encode(utf8.encode(jsonEncode(payload)));
return 'v4.public.$encoded.fakesig';
}
void main() {
group('TwitchIntegrity', () {
test('detects is_bad_bot string flag', () {
final token = _tokenFor({
'client_id': 'kimne78kx3ncx6brgo4mv6wki5h1ko',
'device_id': 'abc123',
'is_bad_bot': 'true',
'exp': '2099-01-01T00:00:00Z',
});
expect(TwitchIntegrity.isBadBot(token), isTrue);
expect(TwitchIntegrity.deviceId(token), 'abc123');
expect(TwitchIntegrity.expirationMs(token), isNotNull);
});
test('detects is_bad_bot bool flag', () {
final token = _tokenFor({
'is_bad_bot': true,
'device_id': 'dev',
});
expect(TwitchIntegrity.isBadBot(token), isTrue);
});
test('returns false when not flagged', () {
final token = _tokenFor({
'is_bad_bot': 'false',
'device_id': 'dev',
});
expect(TwitchIntegrity.isBadBot(token), isFalse);
});
});
}

View file

@ -0,0 +1,203 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:frosty/apis/base_api_client.dart';
import 'package:frosty/apis/twitch_api.dart';
import 'package:frosty/apis/twitch_gql_api.dart';
import 'package:frosty/models/stream.dart';
import 'package:frosty/models/user.dart';
import 'package:frosty/screens/home/stream_list/stream_list_store.dart';
import 'package:frosty/screens/settings/stores/auth_store.dart';
import 'package:frosty/screens/settings/stores/settings_store.dart';
import 'package:mocktail/mocktail.dart';
import 'package:shared_preferences/shared_preferences.dart';
class MockTwitchApi extends Mock implements TwitchApi {}
class MockTwitchGqlApi extends Mock implements TwitchGqlApi {}
void main() {
setUpAll(() {
registerFallbackValue(StreamViewerSort.viewersDesc);
registerFallbackValue(<String>[]);
});
late MockTwitchApi api;
late MockTwitchGqlApi gqlApi;
late AuthStore authStore;
late SettingsStore settingsStore;
StreamTwitch stream(String id, int viewers) => StreamTwitch(
id,
'user$id',
'User$id',
'game',
'Game',
'title',
viewers,
'2026-01-01T00:00:00Z',
'https://cdn/live_user_$id-{width}x{height}.jpg',
);
Future<void> waitUntilIdle(ListStore store) async {
await Future<void>.delayed(Duration.zero);
for (var i = 0; i < 50 && store.isLoading; i++) {
await Future<void>.delayed(const Duration(milliseconds: 5));
}
}
setUp(() {
SharedPreferences.setMockInitialValues({});
api = MockTwitchApi();
gqlApi = MockTwitchGqlApi();
authStore = AuthStore(twitchApi: api, twitchGqlApi: gqlApi);
settingsStore = SettingsStore()..streamLanguages = [];
when(() => api.getUserInfo()).thenAnswer(
(_) async => const UserTwitch(
'123',
'me',
'Me',
'https://cdn/me.png',
),
);
when(() => api.getUserBlockedList(id: any(named: 'id'))).thenAnswer(
(_) async => <UserBlockedTwitch>[],
);
});
group('ListStore tag GQL pagination', () {
test('keeps server ASC order across GQL pages', () async {
when(
() => gqlApi.getStreams(
freeformTags: any(named: 'freeformTags'),
languages: any(named: 'languages'),
sort: any(named: 'sort'),
cursor: any(named: 'cursor'),
first: any(named: 'first'),
authToken: any(named: 'authToken'),
integrityToken: any(named: 'integrityToken'),
deviceId: any(named: 'deviceId'),
),
).thenAnswer((invocation) async {
final cursor = invocation.namedArguments[#cursor] as String?;
if (cursor == null) {
return StreamsTwitch(
[stream('1', 3), stream('2', 7)],
const {'cursor': 'c1'},
);
}
expect(cursor, 'c1');
return StreamsTwitch(
[stream('3', 12), stream('4', 20)],
const {},
);
});
settingsStore.streamViewerSort = StreamViewerSort.viewersAsc;
final store = ListStore(
authStore: authStore,
settingsStore: settingsStore,
twitchApi: api,
twitchGqlApi: gqlApi,
listType: ListType.tag,
freeformTag: 'Deutsch',
);
await waitUntilIdle(store);
expect(store.streams.map((s) => s.userId), ['1', '2']);
expect(store.hasMore, isTrue);
await store.getStreams();
await waitUntilIdle(store);
expect(store.streams.map((s) => s.userId), ['1', '2', '3', '4']);
expect(store.streams.map((s) => s.viewerCount), [3, 7, 12, 20]);
expect(store.hasMore, isFalse);
verifyNever(
() => api.getDirectoryStreams(
languages: any(named: 'languages'),
gameId: any(named: 'gameId'),
freeformTag: any(named: 'freeformTag'),
cursor: any(named: 'cursor'),
minCount: any(named: 'minCount'),
maxPages: any(named: 'maxPages'),
),
);
store.dispose();
});
test('falls back to Helix when GQL page-2 integrity fails', () async {
var gqlCalls = 0;
when(
() => gqlApi.getStreams(
freeformTags: any(named: 'freeformTags'),
languages: any(named: 'languages'),
sort: any(named: 'sort'),
cursor: any(named: 'cursor'),
first: any(named: 'first'),
authToken: any(named: 'authToken'),
integrityToken: any(named: 'integrityToken'),
deviceId: any(named: 'deviceId'),
),
).thenAnswer((invocation) async {
gqlCalls++;
final cursor = invocation.namedArguments[#cursor] as String?;
if (cursor == null) {
return StreamsTwitch(
[stream('1', 100)],
const {'cursor': 'c1'},
);
}
throw const IntegrityCheckException('failed integrity check');
});
when(
() => api.getDirectoryStreams(
languages: any(named: 'languages'),
gameId: any(named: 'gameId'),
freeformTag: any(named: 'freeformTag'),
cursor: any(named: 'cursor'),
minCount: any(named: 'minCount'),
maxPages: any(named: 'maxPages'),
),
).thenAnswer(
(_) async => StreamsTwitch(
[stream('1', 100), stream('9', 50)],
const {},
),
);
final store = ListStore(
authStore: authStore,
settingsStore: settingsStore,
twitchApi: api,
twitchGqlApi: gqlApi,
listType: ListType.tag,
freeformTag: 'Deutsch',
);
await waitUntilIdle(store);
expect(store.streams.map((s) => s.userId), ['1']);
await store.getStreams();
await waitUntilIdle(store);
expect(gqlCalls, greaterThanOrEqualTo(2));
expect(store.streams.map((s) => s.userId), ['1', '9']);
verify(
() => api.getDirectoryStreams(
languages: any(named: 'languages'),
gameId: any(named: 'gameId'),
freeformTag: any(named: 'freeformTag'),
cursor: any(named: 'cursor'),
minCount: any(named: 'minCount'),
maxPages: any(named: 'maxPages'),
),
).called(1);
store.dispose();
});
});
}

View file

@ -1,7 +1,10 @@
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:frosty/apis/base_api_client.dart';
import 'package:frosty/apis/twitch_api.dart';
import 'package:frosty/apis/twitch_gql_api.dart';
import 'package:frosty/apis/twitch_integrity.dart';
import 'package:frosty/models/user.dart';
import 'package:frosty/screens/settings/stores/user_store.dart';
import 'package:mocktail/mocktail.dart';
@ -178,5 +181,88 @@ void main() {
expect(userStore.cachedIsFollowing('999'), isFalse);
});
test('runWithGqlIntegrity refreshes token and retries', () async {
var calls = 0;
when(
() => gqlApi.fetchIntegrityToken(
authToken: any(named: 'authToken'),
deviceId: any(named: 'deviceId'),
),
).thenAnswer(
(_) async => (
token: 'fresh-integrity',
expirationMs: DateTime.now().millisecondsSinceEpoch + 600000,
),
);
final userStore = makeStore(hasModeratorScopes: false);
final result = await userStore.runWithGqlIntegrity<String>(
gqlToken: 'web-token',
action: ({authToken, integrityToken, deviceId}) async {
calls++;
expect(authToken, 'web-token');
expect(deviceId, isNotEmpty);
if (calls == 1) {
throw const IntegrityCheckException('failed integrity check');
}
expect(integrityToken, 'fresh-integrity');
return 'ok';
},
);
expect(result, 'ok');
expect(calls, 2);
verify(
() => gqlApi.fetchIntegrityToken(
authToken: 'web-token',
deviceId: any(named: 'deviceId'),
),
).called(1);
});
test('runWithGqlIntegrity rethrows when no gqlToken', () async {
final userStore = makeStore(hasModeratorScopes: false);
await expectLater(
userStore.runWithGqlIntegrity<void>(
gqlToken: null,
action: ({authToken, integrityToken, deviceId}) async {
throw const IntegrityCheckException('failed integrity check');
},
),
throwsA(isA<IntegrityCheckException>()),
);
verifyNever(
() => gqlApi.fetchIntegrityToken(
authToken: any(named: 'authToken'),
deviceId: any(named: 'deviceId'),
),
);
});
test('applyWebIntegrity stores device id for later GQL calls', () async {
final userStore = makeStore(hasModeratorScopes: false);
await userStore.applyWebIntegrity(
GqlIntegrityCredentials(
integrityToken: 'v4.public.${base64Url.encode(utf8.encode(jsonEncode({
'is_bad_bot': 'false',
'device_id': 'webview-device',
})))}.sig',
deviceId: 'webview-device',
expirationMs: DateTime.now().millisecondsSinceEpoch + 600000,
),
);
final deviceId = await userStore.runWithGqlIntegrity<String>(
gqlToken: 'web-token',
action: ({authToken, integrityToken, deviceId}) async {
expect(deviceId, 'webview-device');
expect(integrityToken, isNotNull);
return deviceId!;
},
);
expect(deviceId, 'webview-device');
});
});
}