Fix eight wire-contract bugs in the Flutter client
Reading the handlers rather than trusting the route names turned up eight fields the client had wrong. None would have raised an error: Go reads a missing form value as "", so each one silently did the wrong thing. The worst is mark-all-read, which scopes on "feed" (articles_handler.go:384) -- posting "feed_url" would have marked every article read instead of one feed's. The articles list filters on "feed" too, so per-feed views were showing everything. The settings endpoints are setters, not toggles: they read expanded_view/digest_enabled from the form and treat anything but "1" as false, so a body-less POST always set them off. OPML import takes a multipart file part, not a form field. add/remove/retry feed and the two dismiss endpoints each wanted a different key than they were sent. The regression test cites the handler and line behind each expectation, since these can only be verified by reading the Go side. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
b64f9ef parent: 7d9686b modified
app/lib/src/api/client.dart +27 -17 | @@ -78,7 +78,7 @@ class GleanClient { | ||
| 78 | 78 | }) async => |
| 79 | 79 | ArticlesResponse.fromJson(await _get('/api/articles/', { |
| 80 | 80 | 'page': page, |
| 81 | - 'feed_url': feedUrl, | |
| 81 | + 'feed': feedUrl, | |
| 82 | 82 | 'status': status, |
| 83 | 83 | 'q': search, |
| 84 | 84 | 'category': category, |
| @@ -105,7 +105,7 @@ class GleanClient { | ||
| 105 | 105 | |
| 106 | 106 | Future<void> markAllRead({String? feedUrl, String? category}) => |
| 107 | 107 | _post('/api/articles/mark-all-read', form: { |
| 108 | - 'feed_url': ?feedUrl, | |
| 108 | + 'feed': ?feedUrl, | |
| 109 | 109 | 'category': ?category, |
| 110 | 110 | }); |
| 111 | 111 | |
| @@ -116,26 +116,32 @@ class GleanClient { | ||
| 116 | 116 | |
| 117 | 117 | Future<Subscription> addFeed(String url, {String? category}) async { |
| 118 | 118 | final j = await _post('/api/feeds/add', form: { |
| 119 | - 'url': url, | |
| 119 | + 'feed_url': url, | |
| 120 | 120 | 'category': ?category, |
| 121 | 121 | }); |
| 122 | 122 | return Subscription.fromJson(j['subscription'] as Map<String, dynamic>); |
| 123 | 123 | } |
| 124 | 124 | |
| 125 | - Future<Subscription> editFeed(String feedUrl, {String? category, String? title}) async { | |
| 125 | + Future<Subscription> editFeed(String feedUrl, {String? category}) async { | |
| 126 | 126 | final j = await _post('/api/feeds/edit', form: { |
| 127 | 127 | 'feed_url': feedUrl, |
| 128 | 128 | 'category': ?category, |
| 129 | - 'title': ?title, | |
| 130 | 129 | }); |
| 131 | 130 | return Subscription.fromJson(j['subscription'] as Map<String, dynamic>); |
| 132 | 131 | } |
| 133 | 132 | |
| 134 | 133 | Future<void> removeFeed(String feedUrl) => |
| 135 | - _delete('/api/feeds/remove', form: {'feed_url': feedUrl}); | |
| 136 | - | |
| 137 | - Future<int> uploadOpml(String opml) async => | |
| 138 | - jsonInt((await _post('/api/feeds/opml/upload', form: {'opml': opml}))['added']); | |
| 134 | + _delete('/api/feeds/remove', form: {'url': feedUrl}); | |
| 135 | + | |
| 136 | + Future<int> uploadOpml(String opml) async { | |
| 137 | + final res = await session.sendFile( | |
| 138 | + '/api/feeds/opml/upload', | |
| 139 | + field: 'opml', | |
| 140 | + filename: 'subscriptions.opml', | |
| 141 | + bytes: utf8.encode(opml), | |
| 142 | + ); | |
| 143 | + return jsonInt(_decode(res)['added']); | |
| 144 | + } | |
| 139 | 145 | |
| 140 | 146 | Future<String> downloadOpml() async { |
| 141 | 147 | final res = await session.get('/api/feeds/opml/download'); |
| @@ -148,7 +154,7 @@ class GleanClient { | ||
| 148 | 154 | Future<void> refreshFeeds() => _post('/api/feeds/refresh'); |
| 149 | 155 | |
| 150 | 156 | Future<void> retryFeed(String feedUrl) => |
| 151 | - _post('/api/feeds/retry', form: {'feed_url': feedUrl}); | |
| 157 | + _post('/api/feeds/retry', form: {'url': feedUrl}); | |
| 152 | 158 | |
| 153 | 159 | Future<List<Subscription>> feedList() async => |
| 154 | 160 | jsonList((await _get('/api/feeds/list'))['subscriptions'], Subscription.fromJson); |
| @@ -206,22 +212,26 @@ class GleanClient { | ||
| 206 | 212 | Future<void> dismissFeedRec(String feedUrl) => |
| 207 | 213 | _post('/api/recs/dismiss-feed', form: {'feed_url': feedUrl}); |
| 208 | 214 | |
| 209 | - Future<void> dismissArticleRec(int articleId) => | |
| 210 | - _post('/api/recs/dismiss-article', form: {'article_id': '$articleId'}); | |
| 215 | + Future<void> dismissArticleRec(String articleUrl) => | |
| 216 | + _post('/api/recs/dismiss-article', form: {'article_url': articleUrl}); | |
| 211 | 217 | |
| 212 | 218 | Future<void> dismissPersonRec(String did) => |
| 213 | - _post('/api/recs/dismiss-person', form: {'did': did}); | |
| 219 | + _post('/api/recs/dismiss-person', form: {'target_did': did}); | |
| 214 | 220 | |
| 215 | 221 | // --- settings --- |
| 216 | 222 | |
| 217 | 223 | Future<List<String>> toggleLanguage(String code) async => |
| 218 | 224 | jsonStrList((await _post('/api/settings/languages/$code'))['languages']); |
| 219 | 225 | |
| 220 | - Future<bool> toggleExpandedView() async => | |
| 221 | - jsonBool((await _post('/api/settings/expanded-view'))['expanded_view']); | |
| 226 | + Future<bool> setExpandedView(bool enabled) async => jsonBool( | |
| 227 | + (await _post('/api/settings/expanded-view', | |
| 228 | + form: {'expanded_view': enabled ? '1' : '0'}))['expanded_view'], | |
| 229 | + ); | |
| 222 | 230 | |
| 223 | - Future<bool> toggleDigestEnabled() async => | |
| 224 | - jsonBool((await _post('/api/settings/digest-enabled'))['digest_enabled']); | |
| 231 | + Future<bool> setDigestEnabled(bool enabled) async => jsonBool( | |
| 232 | + (await _post('/api/settings/digest-enabled', | |
| 233 | + form: {'digest_enabled': enabled ? '1' : '0'}))['digest_enabled'], | |
| 234 | + ); | |
| 225 | 235 | |
| 226 | 236 | // --- digest --- |
| 227 | 237 | |
| @@ -78,7 +78,7 @@ class GleanClient { | |||
| 78 | }) async => | 78 | }) async => |
| 79 | ArticlesResponse.fromJson(await _get('/api/articles/', { | 79 | ArticlesResponse.fromJson(await _get('/api/articles/', { |
| 80 | 'page': page, | 80 | 'page': page, |
| 81 | - 'feed_url': feedUrl, | 81 | + 'feed': feedUrl, |
| 82 | 'status': status, | 82 | 'status': status, |
| 83 | 'q': search, | 83 | 'q': search, |
| 84 | 'category': category, | 84 | 'category': category, |
| @@ -105,7 +105,7 @@ class GleanClient { | |||
| 105 | 105 | ||
| 106 | Future<void> markAllRead({String? feedUrl, String? category}) => | 106 | Future<void> markAllRead({String? feedUrl, String? category}) => |
| 107 | _post('/api/articles/mark-all-read', form: { | 107 | _post('/api/articles/mark-all-read', form: { |
| 108 | - 'feed_url': ?feedUrl, | 108 | + 'feed': ?feedUrl, |
| 109 | 'category': ?category, | 109 | 'category': ?category, |
| 110 | }); | 110 | }); |
| 111 | 111 | ||
| @@ -116,26 +116,32 @@ class GleanClient { | |||
| 116 | 116 | ||
| 117 | Future<Subscription> addFeed(String url, {String? category}) async { | 117 | Future<Subscription> addFeed(String url, {String? category}) async { |
| 118 | final j = await _post('/api/feeds/add', form: { | 118 | final j = await _post('/api/feeds/add', form: { |
| 119 | - 'url': url, | 119 | + 'feed_url': url, |
| 120 | 'category': ?category, | 120 | 'category': ?category, |
| 121 | }); | 121 | }); |
| 122 | return Subscription.fromJson(j['subscription'] as Map<String, dynamic>); | 122 | return Subscription.fromJson(j['subscription'] as Map<String, dynamic>); |
| 123 | } | 123 | } |
| 124 | 124 | ||
| 125 | - Future<Subscription> editFeed(String feedUrl, {String? category, String? title}) async { | 125 | + Future<Subscription> editFeed(String feedUrl, {String? category}) async { |
| 126 | final j = await _post('/api/feeds/edit', form: { | 126 | final j = await _post('/api/feeds/edit', form: { |
| 127 | 'feed_url': feedUrl, | 127 | 'feed_url': feedUrl, |
| 128 | 'category': ?category, | 128 | 'category': ?category, |
| 129 | - 'title': ?title, | ||
| 130 | }); | 129 | }); |
| 131 | return Subscription.fromJson(j['subscription'] as Map<String, dynamic>); | 130 | return Subscription.fromJson(j['subscription'] as Map<String, dynamic>); |
| 132 | } | 131 | } |
| 133 | 132 | ||
| 134 | Future<void> removeFeed(String feedUrl) => | 133 | Future<void> removeFeed(String feedUrl) => |
| 135 | - _delete('/api/feeds/remove', form: {'feed_url': feedUrl}); | 134 | + _delete('/api/feeds/remove', form: {'url': feedUrl}); |
| 136 | - | 135 | + |
| 137 | - Future<int> uploadOpml(String opml) async => | 136 | + Future<int> uploadOpml(String opml) async { |
| 138 | - jsonInt((await _post('/api/feeds/opml/upload', form: {'opml': opml}))['added']); | 137 | + final res = await session.sendFile( |
| 138 | + '/api/feeds/opml/upload', | ||
| 139 | + field: 'opml', | ||
| 140 | + filename: 'subscriptions.opml', | ||
| 141 | + bytes: utf8.encode(opml), | ||
| 142 | + ); | ||
| 143 | + return jsonInt(_decode(res)['added']); | ||
| 144 | + } | ||
| 139 | 145 | ||
| 140 | Future<String> downloadOpml() async { | 146 | Future<String> downloadOpml() async { |
| 141 | final res = await session.get('/api/feeds/opml/download'); | 147 | final res = await session.get('/api/feeds/opml/download'); |
| @@ -148,7 +154,7 @@ class GleanClient { | |||
| 148 | Future<void> refreshFeeds() => _post('/api/feeds/refresh'); | 154 | Future<void> refreshFeeds() => _post('/api/feeds/refresh'); |
| 149 | 155 | ||
| 150 | Future<void> retryFeed(String feedUrl) => | 156 | Future<void> retryFeed(String feedUrl) => |
| 151 | - _post('/api/feeds/retry', form: {'feed_url': feedUrl}); | 157 | + _post('/api/feeds/retry', form: {'url': feedUrl}); |
| 152 | 158 | ||
| 153 | Future<List<Subscription>> feedList() async => | 159 | Future<List<Subscription>> feedList() async => |
| 154 | jsonList((await _get('/api/feeds/list'))['subscriptions'], Subscription.fromJson); | 160 | jsonList((await _get('/api/feeds/list'))['subscriptions'], Subscription.fromJson); |
| @@ -206,22 +212,26 @@ class GleanClient { | |||
| 206 | Future<void> dismissFeedRec(String feedUrl) => | 212 | Future<void> dismissFeedRec(String feedUrl) => |
| 207 | _post('/api/recs/dismiss-feed', form: {'feed_url': feedUrl}); | 213 | _post('/api/recs/dismiss-feed', form: {'feed_url': feedUrl}); |
| 208 | 214 | ||
| 209 | - Future<void> dismissArticleRec(int articleId) => | 215 | + Future<void> dismissArticleRec(String articleUrl) => |
| 210 | - _post('/api/recs/dismiss-article', form: {'article_id': '$articleId'}); | 216 | + _post('/api/recs/dismiss-article', form: {'article_url': articleUrl}); |
| 211 | 217 | ||
| 212 | Future<void> dismissPersonRec(String did) => | 218 | Future<void> dismissPersonRec(String did) => |
| 213 | - _post('/api/recs/dismiss-person', form: {'did': did}); | 219 | + _post('/api/recs/dismiss-person', form: {'target_did': did}); |
| 214 | 220 | ||
| 215 | // --- settings --- | 221 | // --- settings --- |
| 216 | 222 | ||
| 217 | Future<List<String>> toggleLanguage(String code) async => | 223 | Future<List<String>> toggleLanguage(String code) async => |
| 218 | jsonStrList((await _post('/api/settings/languages/$code'))['languages']); | 224 | jsonStrList((await _post('/api/settings/languages/$code'))['languages']); |
| 219 | 225 | ||
| 220 | - Future<bool> toggleExpandedView() async => | 226 | + Future<bool> setExpandedView(bool enabled) async => jsonBool( |
| 221 | - jsonBool((await _post('/api/settings/expanded-view'))['expanded_view']); | 227 | + (await _post('/api/settings/expanded-view', |
| 228 | + form: {'expanded_view': enabled ? '1' : '0'}))['expanded_view'], | ||
| 229 | + ); | ||
| 222 | 230 | ||
| 223 | - Future<bool> toggleDigestEnabled() async => | 231 | + Future<bool> setDigestEnabled(bool enabled) async => jsonBool( |
| 224 | - jsonBool((await _post('/api/settings/digest-enabled'))['digest_enabled']); | 232 | + (await _post('/api/settings/digest-enabled', |
| 233 | + form: {'digest_enabled': enabled ? '1' : '0'}))['digest_enabled'], | ||
| 234 | + ); | ||
| 225 | 235 | ||
| 226 | // --- digest --- | 236 | // --- digest --- |
| 227 | 237 | ||
modified
app/lib/src/api/session.dart +16 -0 | @@ -142,5 +142,21 @@ class GleanSession { | ||
| 142 | 142 | return res; |
| 143 | 143 | } |
| 144 | 144 | |
| 145 | + /// Multipart POST, for the endpoints that take a file rather than form | |
| 146 | + /// fields (OPML import uses r.FormFile). | |
| 147 | + Future<http.Response> sendFile( | |
| 148 | + String path, { | |
| 149 | + required String field, | |
| 150 | + required String filename, | |
| 151 | + required List<int> bytes, | |
| 152 | + }) async { | |
| 153 | + final req = http.MultipartRequest('POST', _uri(path)) | |
| 154 | + ..headers.addAll(_headers(unsafe: true)) | |
| 155 | + ..files.add(http.MultipartFile.fromBytes(field, bytes, filename: filename)); | |
| 156 | + final res = await http.Response.fromStream(await _client.send(req)); | |
| 157 | + _absorb(res); | |
| 158 | + return res; | |
| 159 | + } | |
| 160 | + | |
| 145 | 161 | void close() => _client.close(); |
| 146 | 162 | } |
| @@ -142,5 +142,21 @@ class GleanSession { | |||
| 142 | return res; | 142 | return res; |
| 143 | } | 143 | } |
| 144 | 144 | ||
| 145 | + /// Multipart POST, for the endpoints that take a file rather than form | ||
| 146 | + /// fields (OPML import uses r.FormFile). | ||
| 147 | + Future<http.Response> sendFile( | ||
| 148 | + String path, { | ||
| 149 | + required String field, | ||
| 150 | + required String filename, | ||
| 151 | + required List<int> bytes, | ||
| 152 | + }) async { | ||
| 153 | + final req = http.MultipartRequest('POST', _uri(path)) | ||
| 154 | + ..headers.addAll(_headers(unsafe: true)) | ||
| 155 | + ..files.add(http.MultipartFile.fromBytes(field, bytes, filename: filename)); | ||
| 156 | + final res = await http.Response.fromStream(await _client.send(req)); | ||
| 157 | + _absorb(res); | ||
| 158 | + return res; | ||
| 159 | + } | ||
| 160 | + | ||
| 145 | void close() => _client.close(); | 161 | void close() => _client.close(); |
| 146 | } | 162 | } |
added
app/test/wire_contract_test.dart +132 -0 | new file mode 100644 | ||
| @@ -0,0 +1,132 @@ | ||
| 1 | +import 'dart:convert'; | |
| 2 | + | |
| 3 | +import 'package:flutter_test/flutter_test.dart'; | |
| 4 | +import 'package:http/http.dart' as http; | |
| 5 | +import 'package:http/testing.dart'; | |
| 6 | +import 'package:shared_preferences/shared_preferences.dart'; | |
| 7 | + | |
| 8 | +import 'package:glean_app/src/api/client.dart'; | |
| 9 | +import 'package:glean_app/src/api/session.dart'; | |
| 10 | + | |
| 11 | +/// Locks the exact field names the Go handlers read. | |
| 12 | +/// | |
| 13 | +/// These are invisible when wrong: the server happily accepts a request with | |
| 14 | +/// an unknown field and acts on the zero value, so `mark-all-read` with the | |
| 15 | +/// wrong key silently marks *everything* read rather than one feed. Each | |
| 16 | +/// expectation below cites the handler it mirrors. | |
| 17 | +void main() { | |
| 18 | + setUpAll(() => SharedPreferences.setMockInitialValues({})); | |
| 19 | + | |
| 20 | + late http.BaseRequest captured; | |
| 21 | + late String capturedBody; | |
| 22 | + | |
| 23 | + GleanClient clientCapturing({String responseJson = '{}'}) { | |
| 24 | + final mock = MockClient((req) async { | |
| 25 | + captured = req; | |
| 26 | + capturedBody = req.body; | |
| 27 | + return http.Response(responseJson, 200, | |
| 28 | + headers: {'content-type': 'application/json'}); | |
| 29 | + }); | |
| 30 | + return GleanClient(GleanSession(baseUrl: 'https://example.test', client: mock)); | |
| 31 | + } | |
| 32 | + | |
| 33 | + Map<String, String> form() => Uri.splitQueryString(capturedBody); | |
| 34 | + Map<String, String> query() => captured.url.queryParameters; | |
| 35 | + | |
| 36 | + test('articles filters on "feed" (articles_handler.go:22)', () async { | |
| 37 | + await clientCapturing(responseJson: '{"articles":[]}') | |
| 38 | + .articles(feedUrl: 'https://ex.test/f', status: 'unread', search: 'go', sortOldest: true); | |
| 39 | + expect(query()['feed'], 'https://ex.test/f'); | |
| 40 | + expect(query().containsKey('feed_url'), isFalse); | |
| 41 | + expect(query()['status'], 'unread'); | |
| 42 | + expect(query()['q'], 'go'); | |
| 43 | + expect(query()['sort'], 'oldest'); | |
| 44 | + }); | |
| 45 | + | |
| 46 | + test('mark-all-read scopes on "feed" (articles_handler.go:384)', () async { | |
| 47 | + await clientCapturing().markAllRead(feedUrl: 'https://ex.test/f'); | |
| 48 | + expect(form()['feed'], 'https://ex.test/f'); | |
| 49 | + // The dangerous case: an unknown key means an unscoped mark-all-read. | |
| 50 | + expect(form().containsKey('feed_url'), isFalse); | |
| 51 | + }); | |
| 52 | + | |
| 53 | + test('add feed posts "feed_url" (feeds_handler.go:128)', () async { | |
| 54 | + await clientCapturing(responseJson: '{"subscription":{}}').addFeed('https://ex.test/f'); | |
| 55 | + expect(form()['feed_url'], 'https://ex.test/f'); | |
| 56 | + }); | |
| 57 | + | |
| 58 | + test('remove feed sends "url" (feeds_handler.go:287)', () async { | |
| 59 | + await clientCapturing().removeFeed('https://ex.test/f'); | |
| 60 | + expect(captured.method, 'DELETE'); | |
| 61 | + expect(form()['url'], 'https://ex.test/f'); | |
| 62 | + }); | |
| 63 | + | |
| 64 | + test('retry feed sends "url" (feeds_handler.go:516)', () async { | |
| 65 | + await clientCapturing().retryFeed('https://ex.test/f'); | |
| 66 | + expect(form()['url'], 'https://ex.test/f'); | |
| 67 | + }); | |
| 68 | + | |
| 69 | + test('OPML import uploads a file part named "opml" (feeds_handler.go:359)', () async { | |
| 70 | + await clientCapturing(responseJson: '{"added":3}').uploadOpml('<opml/>'); | |
| 71 | + expect(captured.headers['content-type'], startsWith('multipart/form-data')); | |
| 72 | + expect(capturedBody, contains('name="opml"')); | |
| 73 | + }); | |
| 74 | + | |
| 75 | + test('settings endpoints set a value rather than toggling ' | |
| 76 | + '(settings_handler.go:66,85)', () async { | |
| 77 | + var c = clientCapturing(responseJson: '{"expanded_view":true}'); | |
| 78 | + await c.setExpandedView(true); | |
| 79 | + expect(form()['expanded_view'], '1'); | |
| 80 | + | |
| 81 | + c = clientCapturing(responseJson: '{"expanded_view":false}'); | |
| 82 | + await c.setExpandedView(false); | |
| 83 | + expect(form()['expanded_view'], '0'); | |
| 84 | + | |
| 85 | + c = clientCapturing(responseJson: '{"digest_enabled":true}'); | |
| 86 | + await c.setDigestEnabled(true); | |
| 87 | + expect(form()['digest_enabled'], '1'); | |
| 88 | + }); | |
| 89 | + | |
| 90 | + test('dismiss endpoints key on article_url and target_did ' | |
| 91 | + '(recs_handler.go:7-17)', () async { | |
| 92 | + await clientCapturing().dismissArticleRec('https://ex.test/a'); | |
| 93 | + expect(form()['article_url'], 'https://ex.test/a'); | |
| 94 | + | |
| 95 | + await clientCapturing().dismissPersonRec('did:plc:abc'); | |
| 96 | + expect(form()['target_did'], 'did:plc:abc'); | |
| 97 | + | |
| 98 | + await clientCapturing().dismissFeedRec('https://ex.test/f'); | |
| 99 | + expect(form()['feed_url'], 'https://ex.test/f'); | |
| 100 | + }); | |
| 101 | + | |
| 102 | + test('unsafe methods carry the CSRF token, safe ones do not ' | |
| 103 | + '(middleware.go:90)', () async { | |
| 104 | + final mock = MockClient((req) async { | |
| 105 | + captured = req; | |
| 106 | + return http.Response('{}', 200, headers: { | |
| 107 | + 'content-type': 'application/json', | |
| 108 | + 'set-cookie': 'glean_csrf=tok123; Path=/', | |
| 109 | + }); | |
| 110 | + }); | |
| 111 | + final session = GleanSession(baseUrl: 'https://example.test', client: mock); | |
| 112 | + final c = GleanClient(session); | |
| 113 | + | |
| 114 | + await c.me(); | |
| 115 | + expect(captured.headers.containsKey('X-CSRF-Token'), isFalse); | |
| 116 | + | |
| 117 | + await c.refreshFeeds(); | |
| 118 | + expect(captured.headers['X-CSRF-Token'], 'tok123'); | |
| 119 | + expect(captured.headers['Cookie'], contains('glean_csrf=tok123')); | |
| 120 | + }); | |
| 121 | + | |
| 122 | + test('error responses surface the server message', () async { | |
| 123 | + final mock = MockClient((_) async => http.Response( | |
| 124 | + jsonEncode({'error': 'Please enter your handle.'}), 400, | |
| 125 | + headers: {'content-type': 'application/json'})); | |
| 126 | + final c = GleanClient(GleanSession(baseUrl: 'https://example.test', client: mock)); | |
| 127 | + await expectLater( | |
| 128 | + c.startAuth('x'), | |
| 129 | + throwsA(isA<ApiException>().having((e) => e.message, 'message', 'Please enter your handle.')), | |
| 130 | + ); | |
| 131 | + }); | |
| 132 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,132 @@ | |||
| 1 | +import 'dart:convert'; | ||
| 2 | + | ||
| 3 | +import 'package:flutter_test/flutter_test.dart'; | ||
| 4 | +import 'package:http/http.dart' as http; | ||
| 5 | +import 'package:http/testing.dart'; | ||
| 6 | +import 'package:shared_preferences/shared_preferences.dart'; | ||
| 7 | + | ||
| 8 | +import 'package:glean_app/src/api/client.dart'; | ||
| 9 | +import 'package:glean_app/src/api/session.dart'; | ||
| 10 | + | ||
| 11 | +/// Locks the exact field names the Go handlers read. | ||
| 12 | +/// | ||
| 13 | +/// These are invisible when wrong: the server happily accepts a request with | ||
| 14 | +/// an unknown field and acts on the zero value, so `mark-all-read` with the | ||
| 15 | +/// wrong key silently marks *everything* read rather than one feed. Each | ||
| 16 | +/// expectation below cites the handler it mirrors. | ||
| 17 | +void main() { | ||
| 18 | + setUpAll(() => SharedPreferences.setMockInitialValues({})); | ||
| 19 | + | ||
| 20 | + late http.BaseRequest captured; | ||
| 21 | + late String capturedBody; | ||
| 22 | + | ||
| 23 | + GleanClient clientCapturing({String responseJson = '{}'}) { | ||
| 24 | + final mock = MockClient((req) async { | ||
| 25 | + captured = req; | ||
| 26 | + capturedBody = req.body; | ||
| 27 | + return http.Response(responseJson, 200, | ||
| 28 | + headers: {'content-type': 'application/json'}); | ||
| 29 | + }); | ||
| 30 | + return GleanClient(GleanSession(baseUrl: 'https://example.test', client: mock)); | ||
| 31 | + } | ||
| 32 | + | ||
| 33 | + Map<String, String> form() => Uri.splitQueryString(capturedBody); | ||
| 34 | + Map<String, String> query() => captured.url.queryParameters; | ||
| 35 | + | ||
| 36 | + test('articles filters on "feed" (articles_handler.go:22)', () async { | ||
| 37 | + await clientCapturing(responseJson: '{"articles":[]}') | ||
| 38 | + .articles(feedUrl: 'https://ex.test/f', status: 'unread', search: 'go', sortOldest: true); | ||
| 39 | + expect(query()['feed'], 'https://ex.test/f'); | ||
| 40 | + expect(query().containsKey('feed_url'), isFalse); | ||
| 41 | + expect(query()['status'], 'unread'); | ||
| 42 | + expect(query()['q'], 'go'); | ||
| 43 | + expect(query()['sort'], 'oldest'); | ||
| 44 | + }); | ||
| 45 | + | ||
| 46 | + test('mark-all-read scopes on "feed" (articles_handler.go:384)', () async { | ||
| 47 | + await clientCapturing().markAllRead(feedUrl: 'https://ex.test/f'); | ||
| 48 | + expect(form()['feed'], 'https://ex.test/f'); | ||
| 49 | + // The dangerous case: an unknown key means an unscoped mark-all-read. | ||
| 50 | + expect(form().containsKey('feed_url'), isFalse); | ||
| 51 | + }); | ||
| 52 | + | ||
| 53 | + test('add feed posts "feed_url" (feeds_handler.go:128)', () async { | ||
| 54 | + await clientCapturing(responseJson: '{"subscription":{}}').addFeed('https://ex.test/f'); | ||
| 55 | + expect(form()['feed_url'], 'https://ex.test/f'); | ||
| 56 | + }); | ||
| 57 | + | ||
| 58 | + test('remove feed sends "url" (feeds_handler.go:287)', () async { | ||
| 59 | + await clientCapturing().removeFeed('https://ex.test/f'); | ||
| 60 | + expect(captured.method, 'DELETE'); | ||
| 61 | + expect(form()['url'], 'https://ex.test/f'); | ||
| 62 | + }); | ||
| 63 | + | ||
| 64 | + test('retry feed sends "url" (feeds_handler.go:516)', () async { | ||
| 65 | + await clientCapturing().retryFeed('https://ex.test/f'); | ||
| 66 | + expect(form()['url'], 'https://ex.test/f'); | ||
| 67 | + }); | ||
| 68 | + | ||
| 69 | + test('OPML import uploads a file part named "opml" (feeds_handler.go:359)', () async { | ||
| 70 | + await clientCapturing(responseJson: '{"added":3}').uploadOpml('<opml/>'); | ||
| 71 | + expect(captured.headers['content-type'], startsWith('multipart/form-data')); | ||
| 72 | + expect(capturedBody, contains('name="opml"')); | ||
| 73 | + }); | ||
| 74 | + | ||
| 75 | + test('settings endpoints set a value rather than toggling ' | ||
| 76 | + '(settings_handler.go:66,85)', () async { | ||
| 77 | + var c = clientCapturing(responseJson: '{"expanded_view":true}'); | ||
| 78 | + await c.setExpandedView(true); | ||
| 79 | + expect(form()['expanded_view'], '1'); | ||
| 80 | + | ||
| 81 | + c = clientCapturing(responseJson: '{"expanded_view":false}'); | ||
| 82 | + await c.setExpandedView(false); | ||
| 83 | + expect(form()['expanded_view'], '0'); | ||
| 84 | + | ||
| 85 | + c = clientCapturing(responseJson: '{"digest_enabled":true}'); | ||
| 86 | + await c.setDigestEnabled(true); | ||
| 87 | + expect(form()['digest_enabled'], '1'); | ||
| 88 | + }); | ||
| 89 | + | ||
| 90 | + test('dismiss endpoints key on article_url and target_did ' | ||
| 91 | + '(recs_handler.go:7-17)', () async { | ||
| 92 | + await clientCapturing().dismissArticleRec('https://ex.test/a'); | ||
| 93 | + expect(form()['article_url'], 'https://ex.test/a'); | ||
| 94 | + | ||
| 95 | + await clientCapturing().dismissPersonRec('did:plc:abc'); | ||
| 96 | + expect(form()['target_did'], 'did:plc:abc'); | ||
| 97 | + | ||
| 98 | + await clientCapturing().dismissFeedRec('https://ex.test/f'); | ||
| 99 | + expect(form()['feed_url'], 'https://ex.test/f'); | ||
| 100 | + }); | ||
| 101 | + | ||
| 102 | + test('unsafe methods carry the CSRF token, safe ones do not ' | ||
| 103 | + '(middleware.go:90)', () async { | ||
| 104 | + final mock = MockClient((req) async { | ||
| 105 | + captured = req; | ||
| 106 | + return http.Response('{}', 200, headers: { | ||
| 107 | + 'content-type': 'application/json', | ||
| 108 | + 'set-cookie': 'glean_csrf=tok123; Path=/', | ||
| 109 | + }); | ||
| 110 | + }); | ||
| 111 | + final session = GleanSession(baseUrl: 'https://example.test', client: mock); | ||
| 112 | + final c = GleanClient(session); | ||
| 113 | + | ||
| 114 | + await c.me(); | ||
| 115 | + expect(captured.headers.containsKey('X-CSRF-Token'), isFalse); | ||
| 116 | + | ||
| 117 | + await c.refreshFeeds(); | ||
| 118 | + expect(captured.headers['X-CSRF-Token'], 'tok123'); | ||
| 119 | + expect(captured.headers['Cookie'], contains('glean_csrf=tok123')); | ||
| 120 | + }); | ||
| 121 | + | ||
| 122 | + test('error responses surface the server message', () async { | ||
| 123 | + final mock = MockClient((_) async => http.Response( | ||
| 124 | + jsonEncode({'error': 'Please enter your handle.'}), 400, | ||
| 125 | + headers: {'content-type': 'application/json'})); | ||
| 126 | + final c = GleanClient(GleanSession(baseUrl: 'https://example.test', client: mock)); | ||
| 127 | + await expectLater( | ||
| 128 | + c.startAuth('x'), | ||
| 129 | + throwsA(isA<ApiException>().having((e) => e.message, 'message', 'Please enter your handle.')), | ||
| 130 | + ); | ||
| 131 | + }); | ||
| 132 | +} | ||