diff --git a/.run/file_access_legacy.run.xml b/.run/file_access_legacy.run.xml
new file mode 100644
index 0000000..027686e
--- /dev/null
+++ b/.run/file_access_legacy.run.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/.vscode/launch.json b/.vscode/launch.json
index 5aa1f6d..1ffc76b 100644
--- a/.vscode/launch.json
+++ b/.vscode/launch.json
@@ -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"
+ ]
}
]
-}
\ No newline at end of file
+}
diff --git a/README.md b/README.md
index 449c11d..1fecbed 100644
--- a/README.md
+++ b/README.md
@@ -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为版本号(内部比较)
```
diff --git a/lib/common/utils/app/app_network_tool.dart b/lib/common/utils/app/app_network_tool.dart
index 8e64994..3a9bba5 100644
--- a/lib/common/utils/app/app_network_tool.dart
+++ b/lib/common/utils/app/app_network_tool.dart
@@ -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();
- 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;
diff --git a/lib/common/utils/app/file_access.dart b/lib/common/utils/app/file_access.dart
index 88bec5e..6c13940 100644
--- a/lib/common/utils/app/file_access.dart
+++ b/lib/common/utils/app/file_access.dart
@@ -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 allowedOrigins;
final DateTime Function() now;
int offsetMs = 0, ttlSeconds = 1800;
final _cache = {};
+ 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 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(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 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 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 &&
diff --git a/lib/common/utils/manage/http_manage.dart b/lib/common/utils/manage/http_manage.dart
index 58912ea..bd5049a 100644
--- a/lib/common/utils/manage/http_manage.dart
+++ b/lib/common/utils/manage/http_manage.dart
@@ -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";
diff --git a/lib/common/values/constants.dart b/lib/common/values/constants.dart
index e7d5b54..54b930a 100644
--- a/lib/common/values/constants.dart
+++ b/lib/common/values/constants.dart
@@ -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');
diff --git a/test-vectors/file-upload.json b/test-vectors/file-upload.json
new file mode 100644
index 0000000..bcab051
--- /dev/null
+++ b/test-vectors/file-upload.json
@@ -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"
+ }
+]
diff --git a/test/file_access_mode_test.dart b/test/file_access_mode_test.dart
new file mode 100644
index 0000000..7bb8fa5
--- /dev/null
+++ b/test/file_access_mode_test.dart
@@ -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 = [];
+ 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);
+}
diff --git a/test/file_upload_test.dart b/test/file_upload_test.dart
new file mode 100644
index 0000000..cd978ea
--- /dev/null
+++ b/test/file_upload_test.dart
@@ -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');
+ });
+}