fix(auth): request moderator OAuth scopes only on opt-in
Stop asking for mod scopes at normal login so existing core-only tokens never hit a missing-permissions dialog. Gate moderated-channels Helix, offer an Enable flow for mods/broadcasters, and upgrade atomically. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
2d65aeec29
commit
cc772a7c90
15 changed files with 690 additions and 128 deletions
|
|
@ -215,15 +215,16 @@ void main() {
|
|||
});
|
||||
|
||||
group('validateToken', () {
|
||||
test('returns granted scopes for valid token', () async {
|
||||
test('returns scopes and user id for valid token', () async {
|
||||
dioAdapter.onGet(
|
||||
'https://id.twitch.tv/oauth2/validate',
|
||||
(server) => server.reply(200, twitchValidateTokenResponse),
|
||||
);
|
||||
|
||||
final scopes = await api.validateToken(token: 'valid_token');
|
||||
final info = await api.validateToken(token: 'valid_token');
|
||||
|
||||
expect(scopes, ['chat:read', 'chat:edit']);
|
||||
expect(info?.scopes, ['chat:read', 'chat:edit']);
|
||||
expect(info?.userId, '12345');
|
||||
});
|
||||
|
||||
test('returns null for expired token (401)', () async {
|
||||
|
|
@ -232,9 +233,9 @@ void main() {
|
|||
(server) => server.reply(401, {'message': 'invalid access token'}),
|
||||
);
|
||||
|
||||
final scopes = await api.validateToken(token: 'expired_token');
|
||||
final info = await api.validateToken(token: 'expired_token');
|
||||
|
||||
expect(scopes, isNull);
|
||||
expect(info, isNull);
|
||||
});
|
||||
|
||||
test('rethrows on server error', () {
|
||||
|
|
|
|||
20
test/apis/unauthorized_interceptor_paths_test.dart
Normal file
20
test/apis/unauthorized_interceptor_paths_test.dart
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:frosty/apis/unauthorized_interceptor.dart';
|
||||
|
||||
void main() {
|
||||
group('moderation path helpers', () {
|
||||
test('matches helix moderation paths', () {
|
||||
expect(
|
||||
isTwitchModeratedChannelsPath('/helix/moderation/channels'),
|
||||
isTrue,
|
||||
);
|
||||
expect(isTwitchModeratorActionPath('/helix/moderation/chat'), isTrue);
|
||||
expect(isTwitchModeratorActionPath('/helix/moderation/bans'), isTrue);
|
||||
expect(
|
||||
isTwitchModeratorActionPath('/helix/moderation/channels'),
|
||||
isFalse,
|
||||
);
|
||||
expect(isTwitchModeratedChannelsPath('/helix/users'), isFalse);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -684,5 +684,18 @@ void main() {
|
|||
final updated = original.fromIRCMessage(ircMsg);
|
||||
expect(updated.mod, isFalse);
|
||||
});
|
||||
|
||||
test('missing mod and subscriber tags are false', () {
|
||||
const original = USERSTATE();
|
||||
final ircMsg = IRCMessage(
|
||||
raw: '',
|
||||
command: Command.userState,
|
||||
tags: {'display-name': 'TestUser'},
|
||||
);
|
||||
|
||||
final updated = original.fromIRCMessage(ircMsg);
|
||||
expect(updated.mod, isFalse);
|
||||
expect(updated.subscriber, isFalse);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
99
test/screens/settings/stores/user_store_test.dart
Normal file
99
test/screens/settings/stores/user_store_test.dart
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:frosty/apis/twitch_api.dart';
|
||||
import 'package:frosty/models/user.dart';
|
||||
import 'package:frosty/screens/settings/stores/user_store.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
|
||||
class MockTwitchApi extends Mock implements TwitchApi {}
|
||||
|
||||
void main() {
|
||||
late MockTwitchApi api;
|
||||
|
||||
setUp(() {
|
||||
api = MockTwitchApi();
|
||||
when(() => api.getUserInfo()).thenAnswer(
|
||||
(_) async => const UserTwitch(
|
||||
'12345',
|
||||
'testuser',
|
||||
'TestUser',
|
||||
'https://cdn/profile.png',
|
||||
),
|
||||
);
|
||||
when(() => api.getUserBlockedList(id: any(named: 'id'))).thenAnswer(
|
||||
(_) async => <UserBlockedTwitch>[],
|
||||
);
|
||||
when(() => api.getModeratedChannels(id: any(named: 'id'))).thenAnswer(
|
||||
(_) async => <String>['999'],
|
||||
);
|
||||
});
|
||||
|
||||
group('UserStore moderated channels gating', () {
|
||||
test('init skips moderated-channels without moderator scopes', () async {
|
||||
final store = UserStore(
|
||||
twitchApi: api,
|
||||
hasModeratorScopes: () => false,
|
||||
);
|
||||
|
||||
await store.init();
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
verifyNever(() => api.getModeratedChannels(id: any(named: 'id')));
|
||||
expect(store.moderatedChannels, isEmpty);
|
||||
});
|
||||
|
||||
test('init fetches moderated-channels when moderator scopes exist', () async {
|
||||
final store = UserStore(
|
||||
twitchApi: api,
|
||||
hasModeratorScopes: () => true,
|
||||
);
|
||||
|
||||
await store.init();
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
verify(() => api.getModeratedChannels(id: '12345')).called(1);
|
||||
expect(store.moderatedChannels, ['999']);
|
||||
});
|
||||
|
||||
test('refreshModeratedChannels no-ops without moderator scopes', () async {
|
||||
final store = UserStore(
|
||||
twitchApi: api,
|
||||
hasModeratorScopes: () => false,
|
||||
);
|
||||
await store.init();
|
||||
clearInteractions(api);
|
||||
|
||||
await store.refreshModeratedChannels();
|
||||
verifyNever(() => api.getModeratedChannels(id: any(named: 'id')));
|
||||
});
|
||||
|
||||
test('deleteMessage requires moderator scopes', () async {
|
||||
when(
|
||||
() => api.deleteChatMessage(
|
||||
broadcasterId: any(named: 'broadcasterId'),
|
||||
moderatorId: any(named: 'moderatorId'),
|
||||
messageId: any(named: 'messageId'),
|
||||
),
|
||||
).thenAnswer((_) async => true);
|
||||
|
||||
final store = UserStore(
|
||||
twitchApi: api,
|
||||
hasModeratorScopes: () => false,
|
||||
);
|
||||
await store.init();
|
||||
|
||||
final result = await store.deleteMessage(
|
||||
broadcasterId: '12345',
|
||||
messageId: 'msg',
|
||||
);
|
||||
|
||||
expect(result, isFalse);
|
||||
verifyNever(
|
||||
() => api.deleteChatMessage(
|
||||
broadcasterId: any(named: 'broadcasterId'),
|
||||
moderatorId: any(named: 'moderatorId'),
|
||||
messageId: any(named: 'messageId'),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -2,21 +2,49 @@ import 'package:flutter_test/flutter_test.dart';
|
|||
import 'package:frosty/utils/twitch_oauth_scopes.dart';
|
||||
|
||||
void main() {
|
||||
group('scope sets', () {
|
||||
test('core scopes exclude moderator scopes', () {
|
||||
for (final scope in twitchModeratorScopes) {
|
||||
expect(twitchUserScopes, isNot(contains(scope)));
|
||||
}
|
||||
expect(twitchAllUserScopes, containsAll(twitchUserScopes));
|
||||
expect(twitchAllUserScopes, containsAll(twitchModeratorScopes));
|
||||
});
|
||||
});
|
||||
|
||||
group('hasModeratorScopes', () {
|
||||
test('requires all three moderator scopes', () {
|
||||
expect(hasModeratorScopes(twitchAllUserScopes), isTrue);
|
||||
expect(
|
||||
hasModeratorScopes([
|
||||
...twitchUserScopes,
|
||||
'user:read:moderated_channels',
|
||||
'moderator:manage:chat_messages',
|
||||
]),
|
||||
isFalse,
|
||||
);
|
||||
expect(hasModeratorScopes(twitchUserScopes), isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('missingTwitchScopes', () {
|
||||
test('returns scopes not present on the token', () {
|
||||
test('defaults to core scopes only', () {
|
||||
final missing = missingTwitchScopes(
|
||||
granted: const ['chat:read', 'chat:edit'],
|
||||
);
|
||||
|
||||
expect(missing, contains('user:read:moderated_channels'));
|
||||
expect(missing, contains('moderator:manage:chat_messages'));
|
||||
expect(missing, contains('moderator:manage:banned_users'));
|
||||
expect(missing, contains('user:read:follows'));
|
||||
expect(missing, isNot(contains('user:read:moderated_channels')));
|
||||
expect(missing, isNot(contains('chat:read')));
|
||||
});
|
||||
|
||||
test('returns empty when all required scopes are granted', () {
|
||||
expect(missingTwitchScopes(granted: twitchUserScopes), isEmpty);
|
||||
expect(
|
||||
missingTwitchScopes(granted: twitchUserScopes),
|
||||
missingTwitchScopes(
|
||||
granted: twitchAllUserScopes,
|
||||
required: twitchModeratorScopes,
|
||||
),
|
||||
isEmpty,
|
||||
);
|
||||
});
|
||||
|
|
@ -33,34 +61,34 @@ void main() {
|
|||
);
|
||||
});
|
||||
|
||||
test('lists human-readable missing permissions when logged in', () {
|
||||
test('lists missing core permissions when logged in', () {
|
||||
final message = unauthorizedDialogMessage(
|
||||
isLoggedIn: true,
|
||||
grantedScopes: const [
|
||||
'chat:read',
|
||||
'chat:edit',
|
||||
'user:read:follows',
|
||||
'user:read:blocked_users',
|
||||
'user:manage:blocked_users',
|
||||
'user:manage:chat_color',
|
||||
],
|
||||
grantedScopes: const ['chat:read', 'chat:edit'],
|
||||
);
|
||||
|
||||
expect(message, contains('additional Twitch permissions'));
|
||||
expect(message, contains('View channels you moderate'));
|
||||
expect(message, contains('Delete chat messages as a moderator'));
|
||||
expect(message, contains('Timeout and ban users as a moderator'));
|
||||
expect(message, isNot(contains('Send chat messages')));
|
||||
expect(message, contains('View followed channels'));
|
||||
expect(message, isNot(contains('View channels you moderate')));
|
||||
});
|
||||
|
||||
test('falls back when logged in but no missing scopes are known', () {
|
||||
test('prefers session expired when core scopes are complete', () {
|
||||
expect(
|
||||
unauthorizedDialogMessage(
|
||||
isLoggedIn: true,
|
||||
grantedScopes: twitchUserScopes,
|
||||
),
|
||||
contains('missing permissions'),
|
||||
contains('expired'),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('enableModeratorToolsDialogMessage', () {
|
||||
test('lists moderator permission labels', () {
|
||||
final message = enableModeratorToolsDialogMessage();
|
||||
expect(message, contains('View channels you moderate'));
|
||||
expect(message, contains('Delete chat messages as a moderator'));
|
||||
expect(message, contains('Timeout and ban users as a moderator'));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue