// Copyright 2014 The Flutter Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. import 'dart:async'; import 'dart:io' show HttpServer; import 'package:flutter_tools/src/base/logger.dart'; import 'package:flutter_tools/src/isolated/proxy_middleware.dart'; import 'package:flutter_tools/src/web/devfs_proxy.dart'; import 'package:shelf/shelf.dart'; import 'package:shelf/shelf_io.dart' as shelf_io; import 'package:test/test.dart'; import 'package:yaml/yaml.dart'; void main() { late BufferLogger logger; setUp(() { logger = BufferLogger.test(); }); group('ProxyRule', () { test('fromYaml returns null for invalid YAML', () { final yaml = YamlMap.wrap({'unknown': 'rule'}); final ProxyRule? rule = ProxyRule.fromYaml(yaml, logger); expect(rule, isNull); expect(logger.errorText, contains('Invalid proxy rule in YAML')); }); test('fromYaml returns PrefixProxyRule', () { final yaml = YamlMap.wrap({ 'prefix': '/api', 'target': 'http://localhost:8080', }); final ProxyRule? rule = ProxyRule.fromYaml(yaml, logger); expect(rule, isA()); }); test('fromYaml returns RegexProxyRule', () { final yaml = YamlMap.wrap({ 'regex': '/api/(.*)', 'target': 'http://localhost:8080', }); final ProxyRule? rule = ProxyRule.fromYaml(yaml, logger); expect(rule, isA()); }); }); group('RegexProxyRule', () { test('canHandle returns true for valid regex', () { final yaml = YamlMap.wrap({ 'regex': '/api/(.*)', 'target': 'http://localhost:8080', }); expect(RegexProxyRule.canHandle(yaml), isTrue); }); test('canHandle returns false for missing regex', () { final yaml = YamlMap.wrap({'target': 'http://localhost:8080'}); expect(RegexProxyRule.canHandle(yaml), isFalse); }); test('canHandle returns false for empty regex', () { final yaml = YamlMap.wrap({'regex': '', 'target': 'http://localhost:8080'}); expect(RegexProxyRule.canHandle(yaml), isFalse); }); test('fromYaml creates a RegexProxyRule', () { final yaml = YamlMap.wrap({ 'regex': '^/api/(.*)', 'target': 'http://localhost:8080', 'replace': r'/$1', }); final RegexProxyRule? rule = RegexProxyRule.fromYaml(yaml, logger); expect(rule, isNotNull); expect(rule.toString(), r'{regex: ^/api/(.*), target: http://localhost:8080, replace: /$1}'); }); test('fromYaml logs warning for invalid regex format', () { final yaml = YamlMap.wrap({ 'regex': '[invalid', 'target': 'http://localhost:8080', }); final RegexProxyRule? rule = RegexProxyRule.fromYaml(yaml, logger); expect(rule, isNotNull); expect(logger.warningText, contains('Invalid regex pattern')); expect(rule.toString(), r'{regex: \[invalid, target: http://localhost:8080, replace: null}'); }); test('fromYaml returns null if target is missing', () { final yaml = YamlMap.wrap({'regex': '/api/(.*)'}); final RegexProxyRule? rule = RegexProxyRule.fromYaml(yaml, logger); expect(rule, isNull); expect(logger.errorText, contains('Invalid target for regex')); }); test('matches returns true when regex matches path', () { final rule = RegexProxyRule( pattern: RegExp(r'^/api/v1/users/(.*)'), target: 'http://localhost:8080', ); expect(rule.matches('/api/v1/users/123'), isTrue); expect(rule.matches('/api/v1/users/'), isTrue); }); test('matches returns false when regex does not match path', () { final rule = RegexProxyRule( pattern: RegExp(r'^/api/v1/users/(.*)'), target: 'http://localhost:8080', ); expect(rule.matches('/auth/login'), isFalse); }); test('replace correctly replaces with capture groups', () { final rule = RegexProxyRule( pattern: RegExp(r'/users/(\d+)/profile'), target: 'http://localhost:8080', replacement: r'/api/v1/user/$1', ); expect(rule.replace('/users/123/profile'), '/api/v1/user/123'); }); test('replace correctly replaces without capture groups', () { final rule = RegexProxyRule( pattern: RegExp(r'/oldpath'), target: 'http://localhost:8080', replacement: '/newpath', ); expect(rule.replace('/oldpath/resource'), '/newpath/resource'); }); test('replace returns original path for no replacement', () { final rule = RegexProxyRule( pattern: RegExp(r'/users/(\d+)'), target: 'http://localhost:8080', ); expect(rule.replace('/users/123'), '/users/123'); }); test('replace should replace all occurences', () { final rule = RegexProxyRule( pattern: RegExp(r'/users/(\d+)/profile'), target: 'http://localhost:8080', replacement: r'/api/v1/user/$1', ); expect( rule.replace('/users/456/profile/users/123/profile'), '/api/v1/user/456/api/v1/user/123', ); }); test(r'replace should handle $0 (entire match)', () { final rule = RegexProxyRule( pattern: RegExp(r'^/prefix/(.*)'), target: 'http://localhost:8080', replacement: r'/all$0', ); expect(rule.replace('/prefix/something/else'), '/all/prefix/something/else'); }); test('replace should handle non-matching path gracefully', () { final rule = RegexProxyRule( pattern: RegExp(r'^/api/v1/users/(\d+)(.*)'), target: 'http://localhost:8080', replacement: r'/$1/profile$2', ); expect(rule.replace('/non/matching/path'), '/non/matching/path'); }); test('getTargetUri returns correct Uri', () { final rule = RegexProxyRule( pattern: RegExp(r'^/api/v1/users/(.*)'), target: 'http://localhost:8080/users/', replacement: r'$1', ); final Uri targetUri = rule.targetUri; expect(targetUri.toString(), 'http://localhost:8080/users/'); expect(targetUri.scheme, 'http'); expect(targetUri.host, 'localhost'); expect(targetUri.port, 8080); expect(targetUri.path, '/users/'); }); }); group('PrefixProxyRule', () { test('canHandle returns true for valid prefix', () { final yaml = YamlMap.wrap({ 'prefix': '/api', 'target': 'http://localhost:8080', }); expect(PrefixProxyRule.canHandle(yaml), isTrue); }); test('canHandle returns false for missing prefix', () { final yaml = YamlMap.wrap({'target': 'http://localhost:8080'}); expect(PrefixProxyRule.canHandle(yaml), isFalse); }); test('canHandle returns false for empty prefix', () { final yaml = YamlMap.wrap({'prefix': '', 'target': 'http://localhost:8080'}); expect(PrefixProxyRule.canHandle(yaml), isFalse); }); test('fromYaml creates a PrefixProxyRule', () { final yaml = YamlMap.wrap({ 'prefix': '/old_path', 'target': 'http://localhost:8080/new_path', 'replace': '/new_prefix', }); final PrefixProxyRule? rule = PrefixProxyRule.fromYaml(yaml, logger); expect(rule, isNotNull); expect( rule.toString(), '{prefix: ^/old_path, target: http://localhost:8080/new_path, replace: /new_prefix}', ); }); test('fromYaml returns null if target is missing', () { final yaml = YamlMap.wrap({'prefix': '/api'}); final PrefixProxyRule? rule = PrefixProxyRule.fromYaml(yaml, logger); expect(rule, isNull); expect(logger.errorText, contains('Invalid target for prefix')); }); test('matches returns true when path starts with prefix', () { final rule = PrefixProxyRule(prefix: '/api/v1', target: 'http://localhost:8080'); expect(rule.matches('/api/v1/users'), isTrue); expect(rule.matches('/api/v1'), isTrue); }); test('matches returns false when path does not start with prefix', () { final rule = PrefixProxyRule(prefix: '/api/v1', target: 'http://localhost:8080'); expect(rule.matches('/auth/login/api/v1'), isFalse); expect(rule.matches('/api'), isFalse); }); test('replace correctly replaces the prefix', () { final rule = PrefixProxyRule( prefix: '/api/', target: 'http://localhost:8080', replacement: '/', ); expect(rule.replace('/api/users/123'), '/users/123'); }); test('replace returns original path if no replacement', () { final rule = PrefixProxyRule(prefix: '/api/', target: 'http://localhost:8080'); expect(rule.replace('/api/users/123'), '/api/users/123'); }); test('replace matches exactly', () { final rule = PrefixProxyRule( prefix: '/api', target: 'http://localhost:8080', replacement: '/', ); expect(rule.replace('/api/users/123'), '//users/123'); }); test('replace removes pattern if empty string', () { final rule = PrefixProxyRule( prefix: '/api/users', target: 'http://localhost:8080', replacement: '', ); expect(rule.replace('/api/users/123'), '/123'); }); test('replace replaces first occurence', () { final rule = PrefixProxyRule( prefix: '/api/users', target: 'http://localhost:8080', replacement: '/product', ); expect(rule.replace('/api/users/api/users/123'), '/product/api/users/123'); }); test('replace returns original path for non-matching pattern', () { final rule = PrefixProxyRule( prefix: '/api/users', target: 'http://localhost:8080', replacement: '/product', ); expect(rule.replace('/source/123'), '/source/123'); }); test('getTargetUri returns correct Uri', () { final rule = PrefixProxyRule(prefix: '/api/users', target: 'http://localhost:8080'); final Uri targetUri = rule.targetUri; expect(targetUri.toString(), 'http://localhost:8080'); expect(targetUri.scheme, 'http'); expect(targetUri.host, 'localhost'); expect(targetUri.port, 8080); }); }); group('proxyRequest', () { test('should correctly proxy all request elements', () async { final Uri originalUrl = Uri.parse('http://original.example.com/path'); final Uri finalTargetUrl = Uri.parse('http://target.example.com/newpath'); const originalBody = 'Hello, Shelf Proxy!'; final originalHeaders = { 'Content-Type': 'text/plain', 'X-Custom-Header': 'value', 'content-length': 'ignored', }; final originalContext = {'user': 'testuser', 'auth': true}; final originalRequest = Request( 'POST', originalUrl, headers: originalHeaders, body: originalBody, context: originalContext, ); final Request proxiedRequest = proxyRequest(originalRequest, finalTargetUrl); final expectedHeadersFiltered = Map.fromEntries( originalHeaders.entries.where( (MapEntry entry) => entry.key.toLowerCase() != 'content-length', ), ); for (final MapEntry entry in expectedHeadersFiltered.entries) { expect(proxiedRequest.headers, containsPair(entry.key, entry.value)); } expect(proxiedRequest.method, 'POST'); expect(proxiedRequest.url.toString(), 'newpath'); expect(proxiedRequest.context, originalContext); final String proxiedBody = await proxiedRequest.readAsString(); expect(proxiedBody, originalBody); }); test('should handle an empty request body', () async { final Uri originalUrl = Uri.parse('http://original.example.com/empty'); final Uri finalTargetUrl = Uri.parse('http://target.example.com/empty-new'); final originalRequest = Request('GET', originalUrl); final Request proxiedRequest = proxyRequest(originalRequest, finalTargetUrl); expect(proxiedRequest.method, 'GET'); expect(proxiedRequest.url.toString(), 'empty-new'); expect(await proxiedRequest.readAsString(), ''); }); test('should handle different HTTP methods', () async { final Uri originalUrl = Uri.parse('http://original.example.com/data'); final Uri finalTargetUrl = Uri.parse('http://target.example.com/api/data'); final methods = ['PUT', 'DELETE', 'PATCH', 'GET']; for (final method in methods) { final originalRequest = Request( method, originalUrl, body: method == 'PUT' || method == 'PATCH' ? '{"key": "value"}' : null, ); final Request proxiedRequest = proxyRequest(originalRequest, finalTargetUrl); expect(proxiedRequest.method, method, reason: 'Method "$method" should be preserved'); if (method == 'PUT' || method == 'PATCH') { expect(await proxiedRequest.readAsString(), '{"key": "value"}'); } else { expect(await proxiedRequest.readAsString(), ''); } } }); }); group('getFinalTargetUri', () { test('should add query parameters if original request does have one', () { final rule = RegexProxyRule(pattern: RegExp(r'^/api'), target: 'http://mock-backend.com'); final originalRequest = Request('GET', Uri.parse('http://localhost:8000/api?foo=bar&a=b')); final Uri target = rule.finalTargetUri(originalRequest.requestedUri); expect('$target', 'http://mock-backend.com/api?foo=bar&a=b'); }); test('should not add empty query if original request does not have one', () { final rule = RegexProxyRule(pattern: RegExp(r'^/api'), target: 'http://mock-backend.com'); final originalRequest = Request('GET', Uri.parse('http://localhost:8000/api')); final Uri target = rule.finalTargetUri(originalRequest.requestedUri); expect('$target', 'http://mock-backend.com/api'); }); }); group('proxyMiddleware', () { test('should call inner handler if no rule matches', () async { final rules = [ RegexProxyRule(pattern: RegExp(r'^/other_api'), target: 'http://mock-backend.com'), ]; final Middleware middleware = proxyMiddleware(rules, logger); var innerHandlerCalled = false; FutureOr innerHandler(Request request) { innerHandlerCalled = true; return Response.ok('Inner Handler Response'); } final request = Request('GET', Uri.parse('http://localhost:8080/non_matching_path')); final Response response = await middleware(innerHandler)(request); expect(innerHandlerCalled, isTrue); expect(response.statusCode, 200); expect(await response.readAsString(), 'Inner Handler Response'); }); test( 'should forward 404 response from backend instead of falling back to inner handler', () async { HttpServer? mockServer; try { mockServer = await shelf_io.serve( (Request request) => Response.notFound( '{"error": "Not found"}', headers: {'content-type': 'application/json'}, ), 'localhost', 0, ); final int port = mockServer.port; final rules = [ PrefixProxyRule(prefix: '/api/', target: 'http://localhost:$port/'), ]; final Middleware middleware = proxyMiddleware(rules, logger); var innerHandlerCalled = false; FutureOr innerHandler(Request request) { innerHandlerCalled = true; return Response.ok('index.html'); } final request = Request('GET', Uri.parse('http://localhost:8080/api/missing')); final Response response = await middleware(innerHandler)(request); expect(innerHandlerCalled, isFalse); expect(response.statusCode, 404); expect(await response.readAsString(), '{"error": "Not found"}'); } finally { await mockServer?.close(); } }, ); }); }