mirror of
https://github.com/block/buzz.git
synced 2026-11-03 07:51:22 +00:00
fix(mobile): harden agent card loading and link sign-in
- Don't follow redirects for agent.json, check content-length up front, and stop buffering at the cap. - Any unexpected failure while loading a card re-enables Create and shows a message instead of leaving the page stuck. - A link that needs sign-in is dropped only after sign-in was seen running, not on an effect rerun before reconnectAccount reaches that stage. - Table tests for fetch: request URL and redirects, 403/404, 302, 500, network error, declared and streamed oversize bodies, non-JSON, array. Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz> Signed-off-by: Tom Brow <tomb@squareup.com>
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
@@ -66,7 +67,10 @@ class AgentCreationDraft {
|
||||
try {
|
||||
response = await client
|
||||
.send(
|
||||
http.Request('GET', url)..headers['accept'] = 'application/json',
|
||||
http.Request('GET', url)
|
||||
..headers['accept'] = 'application/json'
|
||||
// A redirect could lead off the CDN; treat it as unavailable.
|
||||
..followRedirects = false,
|
||||
)
|
||||
.timeout(_fetchTimeout);
|
||||
if (response.statusCode == 403 || response.statusCode == 404) {
|
||||
@@ -77,6 +81,9 @@ class AgentCreationDraft {
|
||||
'Couldn’t load this agent card (HTTP ${response.statusCode}).',
|
||||
);
|
||||
}
|
||||
if ((response.contentLength ?? 0) > maxAgentCardBytes) {
|
||||
throw const AgentCardException('This agent card is too large.');
|
||||
}
|
||||
body = await _readBounded(response.stream).timeout(_fetchTimeout);
|
||||
} on AgentCardException {
|
||||
rethrow;
|
||||
@@ -159,13 +166,13 @@ class AgentCreationDraft {
|
||||
}
|
||||
|
||||
static Future<List<int>> _readBounded(Stream<List<int>> stream) async {
|
||||
final bytes = <int>[];
|
||||
final bytes = BytesBuilder(copy: false);
|
||||
await for (final chunk in stream) {
|
||||
bytes.addAll(chunk);
|
||||
if (bytes.length > maxAgentCardBytes) {
|
||||
if (bytes.length + chunk.length > maxAgentCardBytes) {
|
||||
throw const AgentCardException('This agent card is too large.');
|
||||
}
|
||||
bytes.add(chunk);
|
||||
}
|
||||
return bytes;
|
||||
return bytes.takeBytes();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -305,6 +305,11 @@ class AgentsPage extends HookConsumerWidget {
|
||||
() {
|
||||
final link = draft;
|
||||
if (link == null || draftStep.value == _DraftStep.done) return null;
|
||||
// Sign-in counts as attempted only once it is seen running, so a
|
||||
// rerun before reconnectAccount reaches its sign-in stage still waits.
|
||||
if (signingIn && draftStep.value == _DraftStep.signInRequested) {
|
||||
draftStep.value = _DraftStep.signingIn;
|
||||
}
|
||||
if (community == null || controller.busy || signingIn) return null;
|
||||
if (!supportsRemoteAgents(community)) {
|
||||
drop('Remote agents aren’t available in this community.');
|
||||
@@ -312,9 +317,16 @@ class AgentsPage extends HookConsumerWidget {
|
||||
}
|
||||
if (controller.account.account == null) {
|
||||
if (draftStep.value == _DraftStep.wait) {
|
||||
draftStep.value = _DraftStep.signingIn;
|
||||
unawaited(flow.reconnectAccount());
|
||||
} else {
|
||||
draftStep.value = _DraftStep.signInRequested;
|
||||
unawaited(
|
||||
flow.reconnectAccount().catchError((Object error) {
|
||||
debugPrint('agents: link sign-in failed: $error');
|
||||
if (context.mounted) {
|
||||
drop('Couldn’t start Builderlab sign-in. Reopen the link.');
|
||||
}
|
||||
}),
|
||||
);
|
||||
} else if (draftStep.value == _DraftStep.signingIn) {
|
||||
drop(
|
||||
'Sign in to Builderlab to create this agent. Reopen the link.',
|
||||
);
|
||||
@@ -342,10 +354,17 @@ class AgentsPage extends HookConsumerWidget {
|
||||
final AgentCreationDraft resolved;
|
||||
try {
|
||||
resolved = await AgentCreationDraft.fetch(link, client);
|
||||
} on AgentCardException catch (error) {
|
||||
} catch (error) {
|
||||
if (error is! AgentCardException) {
|
||||
debugPrint('agents: agent card failed: $error');
|
||||
}
|
||||
if (context.mounted) {
|
||||
creating.value = false;
|
||||
drop(error.message);
|
||||
drop(
|
||||
error is AgentCardException
|
||||
? error.message
|
||||
: 'Couldn’t open this agent card.',
|
||||
);
|
||||
}
|
||||
return;
|
||||
} finally {
|
||||
@@ -505,5 +524,6 @@ class AgentsPage extends HookConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// Progress of opening a link's draft: waiting on gates, in sign-in, or done.
|
||||
enum _DraftStep { wait, signingIn, done }
|
||||
/// Progress of opening a link's draft: waiting on gates, sign-in requested,
|
||||
/// sign-in seen running, or done.
|
||||
enum _DraftStep { wait, signInRequested, signingIn, done }
|
||||
|
||||
@@ -19,7 +19,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:hooks_riverpod/misc.dart' show Override;
|
||||
import 'package:hooks_riverpod/misc.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../../helpers/remote_agents_fixture.dart';
|
||||
@@ -209,7 +209,6 @@ class AgentsHarness {
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
...overrides,
|
||||
ownedAgentDirectoryProvider.overrideWith(
|
||||
(ref) =>
|
||||
loadOwnedAgentDirectory(fakeOwner.public, relay.fetchHistory),
|
||||
@@ -229,6 +228,7 @@ class AgentsHarness {
|
||||
AgentDirectoryEntry(pubkey: pubkey),
|
||||
],
|
||||
),
|
||||
...overrides,
|
||||
],
|
||||
child: MaterialApp(
|
||||
theme: AppTheme.light(),
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:buzz/features/agents/agent_creation_draft.dart';
|
||||
import 'package:buzz/shared/deeplink/deep_link.dart';
|
||||
import 'package:buzz/shared/hosted/hosted_environment.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
|
||||
void main() {
|
||||
group('buzz://agent/new links', () {
|
||||
@@ -169,4 +174,85 @@ void main() {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
group('fetching agent.json', () {
|
||||
const link = AgentCreationDeepLink('av_1');
|
||||
final big = 'x' * (maxAgentCardBytes + 1);
|
||||
|
||||
Future<Object> outcome(MockClientStreamHandler handler) async {
|
||||
try {
|
||||
return await AgentCreationDraft.fetch(
|
||||
link,
|
||||
MockClient.streaming(handler),
|
||||
);
|
||||
} on AgentCardException catch (error) {
|
||||
return error.message;
|
||||
}
|
||||
}
|
||||
|
||||
http.StreamedResponse respond(
|
||||
int status, [
|
||||
List<List<int>> chunks = const [],
|
||||
int? length,
|
||||
]) => http.StreamedResponse(
|
||||
Stream.fromIterable(chunks),
|
||||
status,
|
||||
contentLength: length,
|
||||
);
|
||||
|
||||
test('requests the build CDN without following redirects', () async {
|
||||
late http.BaseRequest seen;
|
||||
final result = await outcome((request, _) async {
|
||||
seen = request;
|
||||
return respond(200, [utf8.encode('{"name":"Scout"}')]);
|
||||
});
|
||||
expect(result, isA<AgentCreationDraft>());
|
||||
expect(
|
||||
seen.url,
|
||||
HostedEnvironment.avatarsCdnOrigin.replace(path: '/av_1/agent.json'),
|
||||
);
|
||||
expect(seen.followRedirects, isFalse);
|
||||
});
|
||||
|
||||
final failures = <String, (MockClientStreamHandler, String)>{
|
||||
'403 from S3': (
|
||||
(_, _) async => respond(403),
|
||||
'This agent card isn’t available.',
|
||||
),
|
||||
'404': ((_, _) async => respond(404), 'This agent card isn’t available.'),
|
||||
'redirect': (
|
||||
(_, _) async => respond(302),
|
||||
'Couldn’t load this agent card (HTTP 302).',
|
||||
),
|
||||
'500': (
|
||||
(_, _) async => respond(500),
|
||||
'Couldn’t load this agent card (HTTP 500).',
|
||||
),
|
||||
'network error': (
|
||||
(_, _) async => throw http.ClientException('offline'),
|
||||
'Couldn’t load this agent card. Check your connection and reopen the '
|
||||
'link.',
|
||||
),
|
||||
'declared length over the cap': (
|
||||
(_, _) async => respond(200, const [], maxAgentCardBytes + 1),
|
||||
'This agent card is too large.',
|
||||
),
|
||||
'streamed body over the cap': (
|
||||
(_, _) async =>
|
||||
respond(200, [utf8.encode('{"name":"'), utf8.encode(big)]),
|
||||
'This agent card is too large.',
|
||||
),
|
||||
'not JSON': (
|
||||
(_, _) async => respond(200, [utf8.encode('<html>')]),
|
||||
'This agent card is invalid.',
|
||||
),
|
||||
'JSON array': (
|
||||
(_, _) async => respond(200, [utf8.encode('[]')]),
|
||||
'This agent card is invalid.',
|
||||
),
|
||||
};
|
||||
failures.forEach((label, c) {
|
||||
test(label, () async => expect(await outcome(c.$1), c.$2));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user