blob: 9963d16f384c00cc9363c1715238050701b0e641 [file] [log] [blame]
// 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(<String, String>{'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(<String, String>{
'prefix': '/api',
'target': 'http://localhost:8080',
});
final ProxyRule? rule = ProxyRule.fromYaml(yaml, logger);
expect(rule, isA<PrefixProxyRule>());
});
test('fromYaml returns RegexProxyRule', () {
final yaml = YamlMap.wrap(<String, String>{
'regex': '/api/(.*)',
'target': 'http://localhost:8080',
});
final ProxyRule? rule = ProxyRule.fromYaml(yaml, logger);
expect(rule, isA<RegexProxyRule>());
});
});
group('RegexProxyRule', () {
test('canHandle returns true for valid regex', () {
final yaml = YamlMap.wrap(<String, String>{
'regex': '/api/(.*)',
'target': 'http://localhost:8080',
});
expect(RegexProxyRule.canHandle(yaml), isTrue);
});
test('canHandle returns false for missing regex', () {
final yaml = YamlMap.wrap(<String, String>{'target': 'http://localhost:8080'});
expect(RegexProxyRule.canHandle(yaml), isFalse);
});
test('canHandle returns false for empty regex', () {
final yaml = YamlMap.wrap(<String, String>{'regex': '', 'target': 'http://localhost:8080'});
expect(RegexProxyRule.canHandle(yaml), isFalse);
});
test('fromYaml creates a RegexProxyRule', () {
final yaml = YamlMap.wrap(<String, String>{
'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(<String, String>{
'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(<String, String>{'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(<String, String>{
'prefix': '/api',
'target': 'http://localhost:8080',
});
expect(PrefixProxyRule.canHandle(yaml), isTrue);
});
test('canHandle returns false for missing prefix', () {
final yaml = YamlMap.wrap(<String, String>{'target': 'http://localhost:8080'});
expect(PrefixProxyRule.canHandle(yaml), isFalse);
});
test('canHandle returns false for empty prefix', () {
final yaml = YamlMap.wrap(<String, String>{'prefix': '', 'target': 'http://localhost:8080'});
expect(PrefixProxyRule.canHandle(yaml), isFalse);
});
test('fromYaml creates a PrefixProxyRule', () {
final yaml = YamlMap.wrap(<String, String>{
'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(<String, String>{'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 = <String, String>{
'Content-Type': 'text/plain',
'X-Custom-Header': 'value',
'content-length': 'ignored',
};
final originalContext = <String, Object>{'user': 'testuser', 'auth': true};
final originalRequest = Request(
'POST',
originalUrl,
headers: originalHeaders,
body: originalBody,
context: originalContext,
);
final Request proxiedRequest = proxyRequest(originalRequest, finalTargetUrl);
final expectedHeadersFiltered = Map<String, String>.fromEntries(
originalHeaders.entries.where(
(MapEntry<String, String> entry) => entry.key.toLowerCase() != 'content-length',
),
);
for (final MapEntry<String, String> 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 = <String>['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 = <ProxyRule>[
RegexProxyRule(pattern: RegExp(r'^/other_api'), target: 'http://mock-backend.com'),
];
final Middleware middleware = proxyMiddleware(rules, logger);
var innerHandlerCalled = false;
FutureOr<Response> 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 = <ProxyRule>[
PrefixProxyRule(prefix: '/api/', target: 'http://localhost:$port/'),
];
final Middleware middleware = proxyMiddleware(rules, logger);
var innerHandlerCalled = false;
FutureOr<Response> innerHandler(Request request) {
innerHandlerCalled = true;
return Response.ok('<!DOCTYPE html><html>index.html</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();
}
},
);
});
}