feat:日志敏感信息过滤

This commit is contained in:
dzc committed 2026-10-29 15:46:54 +08:00
1 parent 82800373ca
commit ede0f9bc4c
10 files changed
+517 -18

No files matched your search

+7
View File
@@ -0,0 +1,7 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="main.dart(旧版兼容,无配置)" type="FlutterRunConfigurationType" factoryName="Flutter">
<option name="additionalArgs" value="--dart-define=FILE_ACCESS_MODE=legacy" />
<option name="filePath" value="$PROJECT_DIR$/lib/main.dart" />
<method />
</configuration>
</component>
+23 -5
View File
@@ -6,21 +6,39 @@
"configurations": [
{
"name": "pj_supermarket_cloud",
"name": "pj_supermarket_cloud(新版签名)",
"request": "launch",
"type": "dart"
"type": "dart",
"toolArgs": [
"--dart-define=FILE_ACCESS_MODE=signed",
"--dart-define-from-file=.file-access/config.json"
]
},
{
"name": "pj_supermarket_cloud(旧版兼容,无配置)",
"request": "launch",
"type": "dart",
"toolArgs": [
"--dart-define=FILE_ACCESS_MODE=legacy"
]
},
{
"name": "pj_supermarket_cloud (profile mode)",
"request": "launch",
"type": "dart",
"flutterMode": "profile"
"flutterMode": "profile",
"toolArgs": [
"--dart-define-from-file=.file-access/config.json"
]
},
{
"name": "pj_supermarket_cloud (release mode)",
"request": "launch",
"type": "dart",
"flutterMode": "release"
"flutterMode": "release",
"toolArgs": [
"--dart-define-from-file=.file-access/config.json"
]
}
]
}
}
+46 -1
View File
@@ -4,10 +4,55 @@
flutter pub run build_runner build --delete-conflicting-outputs~
```
## 本地运行
项目使用 FVM Flutter 3.27.4。在 IDE 中选择启动模式即可,无需每次填写参数:
| 模式 | VS Code 启动项 | Android Studio 启动项 | 文件请求 |
| --- | --- | --- | --- |
| 新版签名 | `pj_supermarket_cloud(新版签名)` | `main.dart`,Additional run args 配置下述签名参数 | 读取本地配置,图片和上传携带签名 |
| 旧版兼容测试 | `pj_supermarket_cloud(旧版兼容,无配置)` | `main.dart(旧版兼容,无配置)` | 不读取配置文件,图片和上传不携带应用签名 |
直接运行以下命令也可启动。Debug 未传任何文件签名配置时,自动走旧版兼容测试模式:
```bash
fvm flutter run
```
旧版模式会在控制台打印 `[file-access] 旧版兼容测试模式`,不发起文件校时和签名续期。
即使同时提供签名配置,显式 `FILE_ACCESS_MODE=legacy` 也会保持旧版请求行为。
该模式仅用于 Debug;Profile/Release 仍需有效签名配置。配置不完整或密钥错误会报错。
新版签名模式读取 `.file-access/config.json`。该文件已被 Git 忽略,所有环境共用现有的一套配置。
换电脑或在 CI 构建时需要同步这份配置;准备方式见
[文件访问接入说明](../pj_service_cloud/docs/file-access/README.md)。
```bash
fvm flutter run --dart-define-from-file=.file-access/config.json
```
VS Code 的新版 Debug、Profile、Release 启动配置已包含签名参数。
Android Studio 在 `main.dart` 的 Run/Debug Configurations 中,将 Additional run args 设置为
`--dart-define-from-file=.file-access/config.json`。
这些参数在编译时生效,修改后需停止调试并重新运行。
## 旧版兼容验证
1. 选择“旧版兼容,无配置”,完全停止后重新启动,确认控制台显示旧版模式。
2. 使用未缓存的新图片,检查实际请求只有原始文件参数,没有 `appId/kid/ts/sign`。
主屏、副屏、人脸图片下载均使用同一编译模式;已有图片缓存不代表服务器仍允许读取。
3. 在已部署文件校验的后端测试:`mode: enforce` 且 `legacy-dart.enabled: true` 时,
符合 `Dart/<版本> (dart:io)` UA 的裸图片请求可走兼容;关闭兼容后应返回 403。
后端 `observe` 模式仍会放行,不能用于确认拒绝行为。
4. 旧 Dart 兼容只放行图片;PDF、裸 APK、上传不会因 Dart UA 放行。
服务端签发的 `package` 下载链接继续可用。切回“新版签名”后,验证图片和上传请求带签名。
此模式模拟旧版请求,旧 APK 在实际设备上的兼容性仍需用原安装包验证。
## flutter打包
```
flutter build apk --build-name 2.2.0 --build-number 220
fvm flutter build apk --dart-define-from-file=.file-access/config.json --build-name 2.2.0 --build-number 220
--build-name为版本号(展示)
--build-number为版本号(内部比较)
```
+1 -1
View File
@@ -143,7 +143,7 @@ abstract class AppNetworkTool {
final response = await downloadClient.send(request).timeout(const Duration(seconds: 30));
if (response.statusCode == 200) return await response.stream.toBytes().timeout(const Duration(seconds: 30));
await response.stream.drain<void>();
if (response.statusCode != 403 || attempt > 0 || !FileAccess.client.isProtected(url)) return null;
if (response.statusCode != 403 || attempt > 0 || !FileAccess.signingEnabled || !FileAccess.client.isProtected(url)) return null;
await FileAccess.refreshClock(force: true);
}
return null;
+151 -11
View File
@@ -1,7 +1,9 @@
import 'dart:async';
import 'dart:convert';
import 'dart:math';
import 'package:crypto/crypto.dart';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:http/http.dart' as http;
import '../../values/constants.dart';
@@ -13,6 +15,45 @@ const _filePaths = {
'/api/admin/sys-file/oss/file'
};
enum FileAccessMode { signed, legacy }
/// Missing Debug credentials can simulate old clients. Invalid credentials and
/// release/profile builds still require a valid signing configuration.
FileAccessMode resolveFileAccessMode({
required String mode,
required bool debugBuild,
required bool hasConfiguration,
required String appId,
required String kid,
required String key,
}) {
if (!{'auto', 'signed', 'legacy'}.contains(mode)) {
throw StateError('Invalid FILE_ACCESS_MODE: $mode');
}
if (mode == 'legacy') {
if (!debugBuild) {
throw StateError('Legacy file access testing requires a Debug build');
}
return FileAccessMode.legacy;
}
if (mode == 'auto' && debugBuild && !hasConfiguration) {
return FileAccessMode.legacy;
}
var validKey = false;
try {
validKey = base64Decode(key).length >= 32;
} on FormatException {
// A broken configuration must not silently produce unsigned requests.
}
if (appId != 'flutter' ||
!RegExp(r'^[a-zA-Z0-9_-]{1,32}$').hasMatch(kid) ||
!validKey) {
throw StateError(
'Build with --dart-define-from-file=.file-access/config.json');
}
return FileAccessMode.signed;
}
String fileAccessCanonical(String file, String app, String kid, int ts) => [
'v1',
'oss-file-read',
@@ -32,6 +73,24 @@ String fileAccessSignature(
.toString();
}
String fileUploadCanonical(
String target, String app, String kid, int ts, String nonce) {
if (!{'upload', 'inner-upload', 'qn-upload'}.contains(target)) {
throw ArgumentError('Invalid file upload target');
}
return ['v1', 'oss-file-upload', 'POST', target, app, kid, '$ts', nonce]
.join('\n');
}
String fileUploadSignature(
String target, String app, String kid, int ts, String nonce, String key) {
final bytes = base64Decode(key);
if (bytes.length < 32) throw StateError('Invalid file upload key');
return Hmac(sha256, bytes)
.convert(utf8.encode(fileUploadCanonical(target, app, kid, ts, nonce)))
.toString();
}
/// Pure URL/signature logic. No GetX, database, device or main-engine dependency.
class FileAccessClient {
FileAccessClient(
@@ -39,14 +98,17 @@ class FileAccessClient {
required this.appId,
required this.kid,
required this.key,
this.signingEnabled = true,
this.allowedOrigins = const [],
DateTime Function()? now})
: now = now ?? DateTime.now;
final String baseUrl, appId, kid, key;
final bool signingEnabled;
final List<String> allowedOrigins;
final DateTime Function() now;
int offsetMs = 0, ttlSeconds = 1800;
final _cache = <String, ({int ts, String url})>{};
final _uploadRandom = Random.secure();
String resolve(String source) {
final value = source.trim();
@@ -102,6 +164,66 @@ class FileAccessClient {
}
bool isProtected(String source) => _owned(source) != null;
String? uploadTarget(String source) {
var uri = Uri.tryParse(resolve(source));
if (uri != null && !uri.hasScheme) uri = Uri.parse(baseUrl).resolveUri(uri);
if (uri == null ||
!{'http', 'https'}.contains(uri.scheme) ||
uri.host.isEmpty ||
uri.userInfo.isNotEmpty) {
return null;
}
final origins = [
Uri.parse(baseUrl).origin,
...allowedOrigins
.where((e) => e.isNotEmpty)
.map((e) => Uri.parse(e).origin)
];
if (!origins.contains(uri.origin)) return null;
final prefix = Uri.parse(baseUrl).path.replaceFirst(RegExp(r'/+$'), '');
final uploadPath = uri.path;
for (final entry in {
'upload': 'upload',
'inner-upload': 'inner-upload',
'qn-upload': 'qn/upload'
}.entries) {
if ([
'/sys-file/',
'/admin/sys-file/',
'/api/sys-file/',
'/api/admin/sys-file/',
'$prefix/admin/sys-file/',
'$prefix/sys-file/'
].any((start) => uploadPath == '$start${entry.value}')) {
return entry.key;
}
}
return null;
}
Map<String, String> uploadHeaders(String source, {String? nonce}) {
if (!signingEnabled) return {};
final target = uploadTarget(source);
if (target == null) return {};
final issued = (now().millisecondsSinceEpoch + offsetMs) ~/ 1000;
final requestNonce = nonce ??
List.generate(
16,
(_) => _uploadRandom
.nextInt(256)
.toRadixString(16)
.padLeft(2, '0')).join();
return {
'X-File-App-Id': appId,
'X-File-Key-Id': kid,
'X-File-Timestamp': '$issued',
'X-File-Nonce': requestNonce,
'X-File-Signature':
fileUploadSignature(target, appId, kid, issued, requestNonce, key),
};
}
String original(String source) {
final uri = _owned(source);
if (uri == null) return resolve(source);
@@ -116,6 +238,7 @@ class FileAccessClient {
}
String url(String source, {bool force = false}) {
if (!signingEnabled) return original(source);
final uri = _owned(source);
if (uri == null) return resolve(source);
final names = uri.queryParametersAll['fileName'];
@@ -156,15 +279,33 @@ class FileAccessClient {
_cache.clear();
}
bool synchronizeInfo(Map data, int roundTripMs) {
// The Java project's Long serializer returns numeric JSON strings.
final serverTime = int.tryParse('${data['serverTime']}');
final ttl = int.tryParse('${data['ttlSeconds']}');
if (serverTime == null || serverTime <= 0) return false;
synchronize(serverTime, ttl ?? ttlSeconds, roundTripMs);
return true;
}
void clear() => _cache.clear();
}
abstract class FileAccess {
static final mode = resolveFileAccessMode(
mode: Constants.fileAccessMode,
debugBuild: kDebugMode,
hasConfiguration: Constants.hasFileAccessConfiguration,
appId: Constants.fileAccessAppId,
kid: Constants.fileAccessKeyId,
key: Constants.fileAccessKey);
static bool get signingEnabled => mode == FileAccessMode.signed;
static final client = FileAccessClient(
baseUrl: Constants.ossUrl,
appId: Constants.fileAccessAppId,
kid: Constants.fileAccessKeyId,
key: Constants.fileAccessKey,
signingEnabled: signingEnabled,
allowedOrigins: Constants.fileAccessAllowedOrigins.split(','));
static final revision = ValueNotifier<int>(0);
static Timer? _timer;
@@ -177,11 +318,10 @@ abstract class FileAccess {
static void initialize() {
if (_initialized) return;
if (Constants.fileAccessAppId != 'flutter' ||
!RegExp(r'^[a-zA-Z0-9_-]{1,32}$').hasMatch(Constants.fileAccessKeyId) ||
base64Decode(Constants.fileAccessKey).length < 32) {
throw StateError(
'Build with --dart-define-from-file=.file-access/config.json');
if (!signingEnabled) {
_initialized = true;
debugPrint('[file-access] 旧版兼容测试模式:图片和上传不携带应用签名');
return;
}
_initialized = true;
WidgetsBinding.instance.addObserver(_lifecycle);
@@ -195,6 +335,7 @@ abstract class FileAccess {
}
static void resume() {
if (!signingEnabled) return;
_timer ??=
Timer.periodic(const Duration(minutes: 1), (_) => revision.value++);
unawaited(refreshClock());
@@ -206,6 +347,7 @@ abstract class FileAccess {
}
static Future<void> refreshClock({bool force = false}) {
if (!signingEnabled) return Future.value();
if (_pending != null) return _pending!;
if (!force &&
_lastSync != null &&
@@ -227,11 +369,9 @@ abstract class FileAccess {
.timeout(const Duration(seconds: 5));
if (response.statusCode != 200) return;
final data = jsonDecode(response.body)['data'];
if (data is Map && data['serverTime'] is num) {
client.synchronize(
(data['serverTime'] as num).toInt(),
(data['ttlSeconds'] as num?)?.toInt() ?? 1800,
DateTime.now().millisecondsSinceEpoch - started);
if (data is Map &&
client.synchronizeInfo(
data, DateTime.now().millisecondsSinceEpoch - started)) {
_lastSync = DateTime.now();
}
} catch (_) {
@@ -242,7 +382,7 @@ abstract class FileAccess {
}
static Future<bool> retryImage(String source) async {
if (!client.isProtected(source)) return false;
if (!signingEnabled || !client.isProtected(source)) return false;
final raw = client.original(source),
previous = _retries[client.original(source)];
if (previous != null &&
+6
View File
@@ -9,6 +9,7 @@ import 'package:get/get.dart' hide FormData;
import 'package:pj_supermarket_cloud/common/index.dart';
import 'package:pj_supermarket_cloud/common/utils/order/payment_timing_trace.dart';
import 'package:pj_supermarket_cloud/common/utils/app/file_access.dart';
/// 无需向操作员重复提示的服务端业务消息。
const ignoreMsg = ["账号未登录"];
@@ -71,6 +72,8 @@ class HttpManage {
'imagehash',
'localfilepath',
'evidencefilepath',
'xfilesignature',
'xfilenonce',
};
/// 进程内唯一的 HTTP 管理器实例,确保拦截器和连接池不被重复创建。
@@ -99,6 +102,9 @@ class HttpManage {
dio.interceptors.add(
InterceptorsWrapper(
onRequest: (RequestOptions options, handler) async {
if (options.method.toUpperCase() == 'POST') {
options.headers.addAll(FileAccess.client.uploadHeaders(options.uri.toString()));
}
options.extra[_requestStartAtKey] = DateTime.now();
_startPaymentTiming(options);
options.headers["CLIENT-TOC"] = "Y";
+7
View File
@@ -9,6 +9,13 @@ enum LoginVersion {
}
class Constants {
/// Debug 未配置签名时自动模拟旧版;signed 强制签名,legacy 显式测试旧版。
static const fileAccessMode = String.fromEnvironment('FILE_ACCESS_MODE', defaultValue: 'auto');
static const hasFileAccessConfiguration =
bool.hasEnvironment('FILE_ACCESS_APP_ID') ||
bool.hasEnvironment('FILE_ACCESS_KEY_ID') ||
bool.hasEnvironment('FILE_ACCESS_KEY') ||
bool.hasEnvironment('FILE_ACCESS_ALLOWED_ORIGINS');
static const fileAccessAppId = String.fromEnvironment('FILE_ACCESS_APP_ID', defaultValue: 'flutter');
static const fileAccessKeyId = String.fromEnvironment('FILE_ACCESS_KEY_ID', defaultValue: 'v1');
static const fileAccessKey = String.fromEnvironment('FILE_ACCESS_KEY');
+32
View File
@@ -0,0 +1,32 @@
[
{
"target": "upload",
"appId": "admin",
"kid": "v1",
"ts": 1793232000,
"nonce": "00112233445566778899aabbccddeeff",
"key": "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=",
"canonical": "v1\noss-file-upload\nPOST\nupload\nadmin\nv1\n1793232000\n00112233445566778899aabbccddeeff",
"sign": "75fde91ab548250139f260680aeb2e51f831d29d8e4fb3cfb28898528fd3a2a8"
},
{
"target": "qn-upload",
"appId": "h5",
"kid": "v1",
"ts": 1793232000,
"nonce": "ffeeddccbbaa99887766554433221100",
"key": "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=",
"canonical": "v1\noss-file-upload\nPOST\nqn-upload\nh5\nv1\n1793232000\nffeeddccbbaa99887766554433221100",
"sign": "01cef848210b1bc31f5fe7679428b36d8e0f15b9f5517472a023d4d2c008086c"
},
{
"target": "inner-upload",
"appId": "service",
"kid": "v1",
"ts": 1793232000,
"nonce": "0123456789abcdef0123456789abcdef",
"key": "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=",
"canonical": "v1\noss-file-upload\nPOST\ninner-upload\nservice\nv1\n1793232000\n0123456789abcdef0123456789abcdef",
"sign": "0d49bb992e5138cdb7a3f2bd7c83dfd60aedeae2b205898c4a6211c794f35aae"
}
]
+164
View File
@@ -0,0 +1,164 @@
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:pj_supermarket_cloud/common/utils/app/app_network_tool.dart';
import 'package:pj_supermarket_cloud/common/utils/app/file_access.dart';
import 'package:pj_supermarket_cloud/common/values/constants.dart';
void main() {
final testKey = base64Encode(List.generate(32, (index) => index));
FileAccessMode select({
String mode = 'auto',
bool debug = true,
bool configured = false,
String appId = 'flutter',
String kid = 'v1',
String key = '',
}) =>
resolveFileAccessMode(
mode: mode,
debugBuild: debug,
hasConfiguration: configured,
appId: appId,
kid: kid,
key: key,
);
test('an ordinary Debug launch without configuration simulates old clients',
() {
expect(select(), FileAccessMode.legacy);
});
test('valid configuration selects signing in Debug and production builds',
() {
for (final debug in [true, false]) {
for (final mode in ['auto', 'signed']) {
expect(
select(mode: mode, debug: debug, configured: true, key: testKey),
FileAccessMode.signed,
);
}
}
});
test('partial or invalid configuration never silently disables signing', () {
for (final key in [
'',
'not-base64!',
base64Encode([1, 2, 3])
]) {
expect(() => select(configured: true, key: key), throwsStateError);
}
expect(
() => select(configured: true, appId: 'admin', key: testKey),
throwsStateError,
);
expect(
() => select(configured: true, kid: 'invalid key id', key: testKey),
throwsStateError,
);
});
test('production and explicit signed launches reject missing credentials',
() {
expect(() => select(debug: false), throwsStateError);
expect(() => select(mode: 'signed'), throwsStateError);
});
test(
'explicit legacy testing stays unsigned even when credentials are present',
() {
expect(
select(mode: 'legacy', configured: true, key: testKey),
FileAccessMode.legacy,
);
expect(() => select(mode: 'legacy', debug: false), throwsStateError);
expect(() => select(mode: 'typo'), throwsStateError);
});
test(
'legacy image requests omit all signature fields and preserve file names',
() {
final client = FileAccessClient(
baseUrl: 'https://files.example/api',
appId: 'flutter',
kid: 'v1',
key: '',
signingEnabled: false,
);
const source = '/admin/sys-file/oss/file?appId=flutter&kid=v1&ts=1&sign=old'
'&width=20&fileName=literal%252Fname.png';
final url = Uri.parse(client.url(source));
expect(url.path, '/api/admin/sys-file/oss/file');
expect(
url.queryParameters, {'width': '20', 'fileName': 'literal%2Fname.png'});
expect(client.url(source, force: true), url.toString());
expect(client.url('photo.jpg'),
'https://files.example/api/admin/sys-file/oss/file?fileName=photo.jpg');
const external = 'https://other.example/image.png?sign=third-party';
expect(client.url(external), external);
expect(client.url('assets/logo.png'), 'assets/logo.png');
});
test('legacy uploads carry no app signature headers and need no key', () {
final client = FileAccessClient(
baseUrl: 'https://files.example/api',
appId: 'flutter',
kid: 'v1',
key: '',
signingEnabled: false,
);
for (final target in ['upload', 'inner-upload', 'qn/upload']) {
expect(client.uploadHeaders('/admin/sys-file/$target'), isEmpty);
}
});
test('the compiled launch configuration controls image and upload signing',
() {
final expectedSigning = Constants.fileAccessMode != 'legacy' &&
Constants.hasFileAccessConfiguration;
expect(FileAccess.signingEnabled, expectedSigning);
final uri = Uri.parse(FileAccess.client.url('photo.jpg'));
expect(uri.queryParameters.containsKey('sign'), expectedSigning);
expect(
FileAccess.client.uploadHeaders('/admin/sys-file/upload').isNotEmpty,
expectedSigning,
);
// Old clients can still consume package links signed by the server.
const package = 'https://files.example/admin/sys-file/oss/file'
'?appId=package&kid=v1&ts=1&sign=server&fileName=app.apk';
expect(FileAccess.packageUrl(package), package);
});
test(
'legacy image downloads send bare requests and do not retry a rejected signature',
() async {
final requests = <http.Request>[];
final client = MockClient((request) async {
requests.add(request);
return http.Response('', 403);
});
addTearDown(client.close);
expect(await AppNetworkTool.downloadImage('photo.jpg', client: client),
isNull);
expect(requests, hasLength(1));
expect(requests.single.url.queryParameters, {'fileName': 'photo.jpg'});
}, skip: FileAccess.signingEnabled);
testWidgets(
'unsigned startup avoids platform initialization and renewal timers',
(tester) async {
FileAccess.initialize();
FileAccess.initialize();
final before = FileAccess.revision.value;
FileAccess.resume();
await FileAccess.refreshClock(force: true);
expect(await FileAccess.retryImage('photo.jpg'), isFalse);
await tester.pump(const Duration(minutes: 2));
expect(FileAccess.revision.value, before);
FileAccess.pause();
}, skip: FileAccess.signingEnabled);
}
+80
View File
@@ -0,0 +1,80 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:pj_supermarket_cloud/common/utils/app/file_access.dart';
void main() {
final vectors =
jsonDecode(File('test-vectors/file-upload.json').readAsStringSync())
as List;
for (final v in vectors) {
test('shared upload signature: ${v['target']}', () {
expect(
fileUploadCanonical(
v['target'], v['appId'], v['kid'], v['ts'], v['nonce']),
v['canonical']);
expect(
fileUploadSignature(
v['target'], v['appId'], v['kid'], v['ts'], v['nonce'], v['key']),
v['sign']);
});
}
test(
'upload headers are fresh, target-bound and restricted to owned endpoints',
() {
final client = FileAccessClient(
baseUrl: 'https://files.example/api',
appId: 'flutter',
kid: 'v1',
key: vectors[0]['key'],
now: () => DateTime.fromMillisecondsSinceEpoch(1793232000000),
allowedOrigins: ['https://legacy.example']);
for (final entry in {
'/admin/sys-file/upload': 'upload',
'/sys-file/upload': 'upload',
'/api/admin/sys-file/qn/upload': 'qn-upload',
'https://legacy.example/admin/sys-file/inner-upload': 'inner-upload'
}.entries) {
final headers =
client.uploadHeaders(entry.key, nonce: vectors[0]['nonce']);
expect(
headers['X-File-Signature'],
fileUploadSignature(entry.value, 'flutter', 'v1', 1793232000,
vectors[0]['nonce'], vectors[0]['key']));
}
final first = client.uploadHeaders('/admin/sys-file/upload'),
second = client.uploadHeaders('/admin/sys-file/upload');
expect(first['X-File-Nonce'], isNot(second['X-File-Nonce']));
expect(first['X-File-Signature'], isNot(second['X-File-Signature']));
for (final source in [
'https://other.example/admin/sys-file/upload',
'https:///admin/sys-file/upload',
'/admin/user/import',
'/admin/sys-file/oss/file?fileName=x.jpg',
'file:///local.png'
]) {
expect(client.uploadHeaders(source), isEmpty);
}
});
test(
'the Java numeric-string clock response corrects upload and image signatures',
() {
final client = FileAccessClient(
baseUrl: 'https://files.example/api',
appId: 'flutter',
kid: 'v1',
key: vectors[0]['key'],
now: () => DateTime.fromMillisecondsSinceEpoch(1793318400000));
expect(
client.synchronizeInfo(
{'serverTime': '1793232000000', 'ttlSeconds': '1800'}, 0),
isTrue);
expect(client.uploadHeaders('/admin/sys-file/upload')['X-File-Timestamp'],
'1793232000');
expect(
Uri.parse(client.url('photo.jpg')).queryParameters['ts'], '1793232000');
expect(client.synchronizeInfo({'serverTime': 'not-a-time'}, 0), isFalse);
expect(client.uploadHeaders('/admin/sys-file/upload')['X-File-Timestamp'],
'1793232000');
});
}