@_nazmiforreal/flutter-ota 0.1.2 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dart-src/packages/cli-tools/.dart_tool/package_config.json +496 -0
- package/dart-src/packages/cli-tools/.dart_tool/package_graph.json +743 -0
- package/dart-src/packages/cli-tools/lib/src/cli_base.dart +7 -6
- package/dart-src/packages/cli-tools/lib/src/commands/build.dart +46 -49
- package/dart-src/packages/cli-tools/lib/src/commands/bundle.dart +44 -23
- package/dart-src/packages/cli-tools/lib/src/commands/channel.dart +27 -17
- package/dart-src/packages/cli-tools/lib/src/commands/console.dart +11 -6
- package/dart-src/packages/cli-tools/lib/src/commands/deploy.dart +39 -22
- package/dart-src/packages/cli-tools/lib/src/commands/doctor.dart +22 -13
- package/dart-src/packages/cli-tools/lib/src/commands/init.dart +6 -5
- package/dart-src/packages/cli-tools/lib/src/commands/keys.dart +15 -9
- package/dart-src/packages/cli-tools/lib/src/commands/migrate.dart +14 -8
- package/dart-src/packages/cli-tools/lib/src/commands/rollback.dart +37 -12
- package/dart-src/packages/cli-tools/lib/src/operations.dart +45 -0
- package/dart-src/packages/cli-tools/lib/src/pack.dart +9 -0
- package/dart-src/packages/cli-tools/lib/src/ui/ui.dart +234 -0
- package/dart-src/packages/cli-tools/pubspec.lock +638 -0
- package/dart-src/packages/cli-tools/test/backends_test.dart +207 -0
- package/dart-src/packages/cli-tools/test/build_pack_test.dart +101 -0
- package/dart-src/packages/cli-tools/test/cli_test.dart +442 -0
- package/dart-src/packages/cli-tools/test/mocks/mock_aws_s3_client.dart +83 -0
- package/dart-src/packages/cli-tools/test/mocks/mock_d1_client.dart +256 -0
- package/dart-src/packages/cli-tools/test/mocks/mock_postgres_client.dart +347 -0
- package/dart-src/packages/cli-tools/test/standalone_backend_test.dart +315 -0
- package/dart-src/packages/core/test/rollout_test.dart +128 -0
- package/dart-src/packages/core/test/semver_test.dart +52 -0
- package/dart-src/packages/core/test/semver_vectors.json +1 -0
- package/dart-src/packages/core/test/types_test.dart +129 -0
- package/dart-src/packages/core/test/uuid_test.dart +38 -0
- package/dart-src/plugins/aws/test/aws_database_test.dart +167 -0
- package/dart-src/plugins/aws/test/aws_lambda_edge_storage_test.dart +190 -0
- package/dart-src/plugins/aws/test/aws_storage_test.dart +73 -0
- package/dart-src/plugins/aws/test/mock_aws_s3_client.dart +83 -0
- package/dart-src/plugins/cloudflare/test/cloudflare_worker_database_test.dart +122 -0
- package/dart-src/plugins/cloudflare/test/d1_bundle_mapper_test.dart +106 -0
- package/dart-src/plugins/cloudflare/test/d1_database_test.dart +190 -0
- package/dart-src/plugins/cloudflare/test/mock_d1_client.dart +256 -0
- package/dart-src/plugins/cloudflare/test/mock_r2_client.dart +83 -0
- package/dart-src/plugins/cloudflare/test/r2_storage_test.dart +77 -0
- package/dart-src/plugins/plugin-core/test/asset_storage_layout_test.dart +166 -0
- package/dart-src/plugins/plugin-core/test/bundle_unit_of_work_store_test.dart +74 -0
- package/dart-src/plugins/plugin-core/test/bundle_unit_of_work_test.dart +332 -0
- package/dart-src/plugins/plugin-core/test/create_blob_database_plugin_test.dart +711 -0
- package/dart-src/plugins/plugin-core/test/create_database_plugin_test.dart +294 -0
- package/dart-src/plugins/plugin-core/test/create_storage_plugin_test.dart +192 -0
- package/dart-src/plugins/plugin-core/test/generate_min_bundle_id_test.dart +51 -0
- package/dart-src/plugins/plugin-core/test/get_update_info_test.dart +1600 -0
- package/dart-src/plugins/plugin-core/test/helpers_test.dart +259 -0
- package/dart-src/plugins/plugin-core/test/query_bundles_test.dart +139 -0
- package/dart-src/plugins/plugin-core/test/request_update_bundle_state_test.dart +86 -0
- package/dart-src/plugins/plugin-core/test/uuidv7_test.dart +77 -0
- package/dart-src/plugins/postgres/test/mock_postgres_client.dart +297 -0
- package/dart-src/plugins/postgres/test/postgres_bundle_mapper_test.dart +140 -0
- package/dart-src/plugins/postgres/test/postgres_database_test.dart +181 -0
- package/dart-src/plugins/standalone/test/standalone_test.dart +264 -0
- package/dart-src/plugins/supabase/test/mock_supabase_client.dart +654 -0
- package/dart-src/plugins/supabase/test/supabase_bundle_mapper_test.dart +112 -0
- package/dart-src/plugins/supabase/test/supabase_database_test.dart +207 -0
- package/dart-src/plugins/supabase/test/supabase_storage_test.dart +175 -0
- package/package.json +1 -1
- package/scripts/vendor.sh +29 -0
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import 'package:flutter_patcher_core/flutter_patcher_core.dart';
|
|
2
|
+
import 'package:test/test.dart';
|
|
3
|
+
|
|
4
|
+
Bundle _sample() => Bundle(
|
|
5
|
+
id: '019b76da-a800-7cc2-b5a5-c8f4a1e9d001',
|
|
6
|
+
platform: Platform.android,
|
|
7
|
+
shouldForceUpdate: false,
|
|
8
|
+
enabled: true,
|
|
9
|
+
fileHash: 'a' * 64,
|
|
10
|
+
storageUri: 'supabase-storage://flutter-patcher-storage/bundles/x/bundle.zip',
|
|
11
|
+
gitCommitHash: 'deadbeef',
|
|
12
|
+
message: 'test bundle',
|
|
13
|
+
channel: 'production',
|
|
14
|
+
targetAppVersion: '^1.2.0',
|
|
15
|
+
metadata: const BundleMetadata(appVersion: '1.2.3'),
|
|
16
|
+
rolloutCohortCount: 137,
|
|
17
|
+
targetCohorts: ['qa-group'],
|
|
18
|
+
patches: [
|
|
19
|
+
BundlePatchArtifact(
|
|
20
|
+
baseBundleId: 'base-id',
|
|
21
|
+
baseFileHash: 'b' * 64,
|
|
22
|
+
patchFileHash: 'c' * 64,
|
|
23
|
+
patchStorageUri: 'supabase-storage://bucket/patches/p.bsdiff',
|
|
24
|
+
),
|
|
25
|
+
],
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
void main() {
|
|
29
|
+
group('Bundle JSON', () {
|
|
30
|
+
test('camelCase roundtrip preserves all fields', () {
|
|
31
|
+
final b = _sample();
|
|
32
|
+
final parsed = Bundle.fromJson(b.toJson());
|
|
33
|
+
expect(parsed.id, b.id);
|
|
34
|
+
expect(parsed.platform, Platform.android);
|
|
35
|
+
expect(parsed.channel, 'production');
|
|
36
|
+
expect(parsed.targetAppVersion, '^1.2.0');
|
|
37
|
+
expect(parsed.metadata?.appVersion, '1.2.3');
|
|
38
|
+
expect(parsed.rolloutCohortCount, 137);
|
|
39
|
+
expect(parsed.targetCohorts, ['qa-group']);
|
|
40
|
+
expect(parsed.patches?.single, b.patches!.single);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('snake_case DB row parses (PostgREST shape)', () {
|
|
44
|
+
final row = _sample().toSqlJson();
|
|
45
|
+
final parsed = Bundle.fromJson(row.cast<String, dynamic>());
|
|
46
|
+
expect(parsed.id, _sample().id);
|
|
47
|
+
expect(parsed.shouldForceUpdate, isFalse);
|
|
48
|
+
expect(parsed.patches?.single.baseBundleId, 'base-id');
|
|
49
|
+
expect(parsed.rolloutCohortCount, 137);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('defaults applied on sparse json', () {
|
|
53
|
+
final parsed = Bundle.fromJson({
|
|
54
|
+
'id': 'x',
|
|
55
|
+
'platform': 'android',
|
|
56
|
+
'file_hash': 'h',
|
|
57
|
+
'storage_uri': 's://u',
|
|
58
|
+
});
|
|
59
|
+
expect(parsed.channel, 'production');
|
|
60
|
+
expect(parsed.enabled, isFalse);
|
|
61
|
+
expect(parsed.shouldForceUpdate, isFalse);
|
|
62
|
+
expect(parsed.message, isNull);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('sql row carries constraint-relevant fields', () {
|
|
66
|
+
final sql = _sample().toSqlJson();
|
|
67
|
+
expect(sql.containsKey('target_app_version'), isTrue);
|
|
68
|
+
expect(sql['metadata'], isA<Map>());
|
|
69
|
+
// XOR constraint mirrors DB CHECK:
|
|
70
|
+
final hasFingerprint = sql['fingerprint_hash'] != null;
|
|
71
|
+
final hasVersion = sql['target_app_version'] != null;
|
|
72
|
+
expect(hasFingerprint || hasVersion, isTrue);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('equality by wire fields', () {
|
|
76
|
+
expect(_sample(), equals(_sample()));
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
group('AppUpdateInfo union', () {
|
|
81
|
+
test('parses UP_TO_DATE', () {
|
|
82
|
+
final info = AppUpdateInfo.fromJson({'status': 'UP_TO_DATE'});
|
|
83
|
+
expect(info, isA<AppUpToDateInfo>());
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('parses UPDATE with changed assets', () {
|
|
87
|
+
final info = AppUpdateInfo.fromJson({
|
|
88
|
+
'id': 'new-id',
|
|
89
|
+
'shouldForceUpdate': true,
|
|
90
|
+
'message': null,
|
|
91
|
+
'status': 'UPDATE',
|
|
92
|
+
'fileUrl': 'https://cdn/b.zip',
|
|
93
|
+
'fileHash': 'd' * 64,
|
|
94
|
+
'changedAssets': {
|
|
95
|
+
'assets/logo.png': {
|
|
96
|
+
'fileHash': 'e' * 64,
|
|
97
|
+
'file': {'url': 'https://cdn/logo.png', 'compression': null},
|
|
98
|
+
'patch': null,
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
}) as AppUpdateAvailableInfo;
|
|
102
|
+
expect(info.status, UpdateStatus.update);
|
|
103
|
+
expect(info.changedAssets?['assets/logo.png']?.file?.url,
|
|
104
|
+
'https://cdn/logo.png');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('available info serializes', () {
|
|
108
|
+
const info = AppUpdateAvailableInfo(
|
|
109
|
+
id: 'i',
|
|
110
|
+
shouldForceUpdate: true,
|
|
111
|
+
message: null,
|
|
112
|
+
status: UpdateStatus.rollback,
|
|
113
|
+
fileUrl: null,
|
|
114
|
+
fileHash: null,
|
|
115
|
+
);
|
|
116
|
+
expect(info.toJson()['status'], 'ROLLBACK');
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
group('enums', () {
|
|
121
|
+
test('values match hot-updater strings', () {
|
|
122
|
+
expect(Platform.ios.value, 'ios');
|
|
123
|
+
expect(Platform.android.value, 'android');
|
|
124
|
+
expect(AppUpdateStatus.upToDate.value, 'UP_TO_DATE');
|
|
125
|
+
expect(UpdateStrategy.fingerprint.value, 'fingerprint');
|
|
126
|
+
expect(() => Platform.fromValue('windows'), throwsArgumentError);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import 'package:flutter_patcher_core/flutter_patcher_core.dart';
|
|
2
|
+
import 'package:test/test.dart';
|
|
3
|
+
|
|
4
|
+
void main() {
|
|
5
|
+
group('uuidV7', () {
|
|
6
|
+
test('generates RFC-compliant v7 strings', () {
|
|
7
|
+
final id = uuidV7();
|
|
8
|
+
expect(id.length, 36);
|
|
9
|
+
expect(isUuidV7(id), isTrue, reason: id);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
test('encodes provided timestamp', () {
|
|
13
|
+
final ts = DateTime.utc(2026, 1, 1);
|
|
14
|
+
final id = uuidV7(now: ts);
|
|
15
|
+
final tsHex = ts.millisecondsSinceEpoch.toRadixString(16).padLeft(12, '0');
|
|
16
|
+
expect(id.substring(0, 8), tsHex.substring(0, 8));
|
|
17
|
+
expect(id.substring(9, 13), tsHex.substring(8, 12));
|
|
18
|
+
expect(id[14], '7');
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test('lexicographic order matches chronological order', () {
|
|
22
|
+
final base = DateTime.utc(2026, 5, 5, 12);
|
|
23
|
+
final earlier = uuidV7(now: base);
|
|
24
|
+
final later = uuidV7(now: base.add(const Duration(seconds: 10)));
|
|
25
|
+
expect(compareBundleIds(earlier, later), -1);
|
|
26
|
+
expect(compareBundleIds(later, earlier), 1);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('uniqueness over bulk generation', () {
|
|
30
|
+
final ids = List.generate(5000, (_) => uuidV7()).toSet();
|
|
31
|
+
expect(ids.length, 5000);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('nilUuid matches hot-updater sentinel', () {
|
|
36
|
+
expect(nilUuid, '00000000-0000-0000-0000-000000000000');
|
|
37
|
+
});
|
|
38
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import 'package:flutter_patcher_core/flutter_patcher_core.dart'
|
|
2
|
+
show
|
|
3
|
+
AppVersionGetBundlesArgs,
|
|
4
|
+
Bundle,
|
|
5
|
+
Platform,
|
|
6
|
+
nilUuid;
|
|
7
|
+
import 'package:flutter_patcher_plugin_core/flutter_patcher_plugin_core.dart'
|
|
8
|
+
show DatabaseBundleQueryOptions, DatabasePlugin;
|
|
9
|
+
import 'package:flutter_patcher_aws/flutter_patcher_aws.dart'
|
|
10
|
+
show S3DatabaseConfig, AwsS3ClientLike, AwsCloudFrontClientLike, s3Database;
|
|
11
|
+
import 'package:test/test.dart';
|
|
12
|
+
|
|
13
|
+
import 'mock_aws_s3_client.dart' show Store, MockAwsS3Client;
|
|
14
|
+
|
|
15
|
+
/// Records CloudFront invalidation calls for assertions.
|
|
16
|
+
class MockCloudFrontClient implements AwsCloudFrontClientLike {
|
|
17
|
+
MockCloudFrontClient();
|
|
18
|
+
|
|
19
|
+
final List<(String, List<String>)> invalidations = [];
|
|
20
|
+
|
|
21
|
+
@override
|
|
22
|
+
Future<void> createInvalidation(
|
|
23
|
+
String distributionId,
|
|
24
|
+
List<String> paths, {
|
|
25
|
+
bool shouldWait = false,
|
|
26
|
+
}) async {
|
|
27
|
+
invalidations.add((distributionId, List<String>.from(paths)));
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
Bundle _makeBundle({
|
|
32
|
+
required String id,
|
|
33
|
+
String channel = 'production',
|
|
34
|
+
String? targetAppVersion,
|
|
35
|
+
String? fingerprintHash,
|
|
36
|
+
}) =>
|
|
37
|
+
Bundle(
|
|
38
|
+
id: id,
|
|
39
|
+
channel: channel,
|
|
40
|
+
enabled: true,
|
|
41
|
+
shouldForceUpdate: false,
|
|
42
|
+
fileHash: 'h-$id',
|
|
43
|
+
platform: Platform.android,
|
|
44
|
+
targetAppVersion: targetAppVersion,
|
|
45
|
+
storageUri: 's3://test-bucket/$id',
|
|
46
|
+
fingerprintHash: fingerprintHash,
|
|
47
|
+
message: 'msg-$id',
|
|
48
|
+
patches: null,
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
void main() {
|
|
52
|
+
group('aws s3Database', () {
|
|
53
|
+
late Store store;
|
|
54
|
+
late MockCloudFrontClient cf;
|
|
55
|
+
late DatabasePlugin plugin;
|
|
56
|
+
|
|
57
|
+
S3DatabaseConfig config({
|
|
58
|
+
required AwsS3ClientLike Function(S3DatabaseConfig) clientFactory,
|
|
59
|
+
String? cloudfrontDistributionId,
|
|
60
|
+
}) =>
|
|
61
|
+
S3DatabaseConfig(
|
|
62
|
+
bucketName: 'test-bucket',
|
|
63
|
+
region: 'us-east-1',
|
|
64
|
+
accessKeyId: 'ak',
|
|
65
|
+
secretAccessKey: 'sk',
|
|
66
|
+
clientFactory: clientFactory,
|
|
67
|
+
cloudfrontDistributionId: cloudfrontDistributionId,
|
|
68
|
+
cloudfrontClientFactory: (_) => cf,
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
setUp(() {
|
|
72
|
+
store = Store();
|
|
73
|
+
cf = MockCloudFrontClient();
|
|
74
|
+
plugin = s3Database(config(
|
|
75
|
+
clientFactory: (_) => MockAwsS3Client(store),
|
|
76
|
+
cloudfrontDistributionId: 'DIST123',
|
|
77
|
+
))();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('plugin metadata', () {
|
|
81
|
+
expect(plugin.name, 's3Database');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('commitBundle writes update.json and invalidates CloudFront',
|
|
85
|
+
() async {
|
|
86
|
+
await plugin.appendBundle(
|
|
87
|
+
_makeBundle(id: 'b1', targetAppVersion: '1.0.0'),
|
|
88
|
+
);
|
|
89
|
+
await plugin.commitBundle();
|
|
90
|
+
|
|
91
|
+
final keys = store.objects.keys.toList();
|
|
92
|
+
expect(
|
|
93
|
+
keys,
|
|
94
|
+
contains('production/android/1.0.0/update.json'),
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
expect(cf.invalidations, isNotEmpty);
|
|
98
|
+
expect(cf.invalidations.first.$1, 'DIST123');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('getUpdateInfo (appVersion) resolves committed bundle', () async {
|
|
102
|
+
await plugin.appendBundle(
|
|
103
|
+
_makeBundle(id: 'b1', targetAppVersion: '1.0.0'),
|
|
104
|
+
);
|
|
105
|
+
await plugin.commitBundle();
|
|
106
|
+
|
|
107
|
+
final info = await plugin.getUpdateInfo(
|
|
108
|
+
AppVersionGetBundlesArgs(
|
|
109
|
+
platform: Platform.android,
|
|
110
|
+
bundleId: nilUuid,
|
|
111
|
+
appVersion: '1.0.0',
|
|
112
|
+
channel: 'production',
|
|
113
|
+
minBundleId: nilUuid,
|
|
114
|
+
),
|
|
115
|
+
);
|
|
116
|
+
expect(info, isNotNull);
|
|
117
|
+
expect(info!.id, 'b1');
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test('getBundles returns committed bundles', () async {
|
|
121
|
+
await plugin.appendBundle(
|
|
122
|
+
_makeBundle(id: 'b1', targetAppVersion: '1.0.0'),
|
|
123
|
+
);
|
|
124
|
+
await plugin.commitBundle();
|
|
125
|
+
|
|
126
|
+
final result = await plugin.getBundles(
|
|
127
|
+
const DatabaseBundleQueryOptions(limit: 100, offset: 0),
|
|
128
|
+
);
|
|
129
|
+
expect(result.data, hasLength(1));
|
|
130
|
+
expect(result.data.first.id, 'b1');
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test('deleteBundle removes update.json and invalidates', () async {
|
|
134
|
+
await plugin.appendBundle(
|
|
135
|
+
_makeBundle(id: 'b1', targetAppVersion: '1.0.0'),
|
|
136
|
+
);
|
|
137
|
+
await plugin.commitBundle();
|
|
138
|
+
expect(store.objects.keys, contains('production/android/1.0.0/update.json'));
|
|
139
|
+
|
|
140
|
+
cf.invalidations.clear();
|
|
141
|
+
final bundle = (await plugin.getBundles(
|
|
142
|
+
const DatabaseBundleQueryOptions(limit: 100, offset: 0),
|
|
143
|
+
))
|
|
144
|
+
.data
|
|
145
|
+
.first;
|
|
146
|
+
await plugin.deleteBundle(bundle);
|
|
147
|
+
await plugin.commitBundle();
|
|
148
|
+
|
|
149
|
+
expect(
|
|
150
|
+
store.objects.keys,
|
|
151
|
+
isNot(contains('production/android/1.0.0/update.json')),
|
|
152
|
+
);
|
|
153
|
+
expect(cf.invalidations, isNotEmpty);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test('no CloudFront invalidation when distribution id omitted', () async {
|
|
157
|
+
final noCf = s3Database(config(
|
|
158
|
+
clientFactory: (_) => MockAwsS3Client(store),
|
|
159
|
+
))();
|
|
160
|
+
await noCf.appendBundle(
|
|
161
|
+
_makeBundle(id: 'b1', targetAppVersion: '1.0.0'),
|
|
162
|
+
);
|
|
163
|
+
await noCf.commitBundle();
|
|
164
|
+
expect(cf.invalidations, isEmpty);
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import 'dart:convert' show base64, base64Decode, utf8;
|
|
2
|
+
import 'dart:math' show Random;
|
|
3
|
+
import 'dart:typed_data' show Uint8List;
|
|
4
|
+
|
|
5
|
+
import 'package:asn1lib/asn1lib.dart';
|
|
6
|
+
import 'package:flutter_patcher_aws/flutter_patcher_aws.dart'
|
|
7
|
+
show
|
|
8
|
+
AwsLambdaEdgeStorageConfig,
|
|
9
|
+
cloudfrontSignedUrl,
|
|
10
|
+
s3LambdaEdgeStorage,
|
|
11
|
+
verifyCloudfrontSignedUrl;
|
|
12
|
+
import 'package:flutter_patcher_plugin_core/flutter_patcher_plugin_core.dart'
|
|
13
|
+
show RuntimeStorageProfile;
|
|
14
|
+
import 'package:pointycastle/export.dart'
|
|
15
|
+
show
|
|
16
|
+
FortunaRandom,
|
|
17
|
+
KeyParameter,
|
|
18
|
+
ParametersWithRandom,
|
|
19
|
+
RSAKeyGenerator,
|
|
20
|
+
RSAKeyGeneratorParameters,
|
|
21
|
+
RSAPrivateKey,
|
|
22
|
+
RSAPublicKey;
|
|
23
|
+
import 'package:test/test.dart';
|
|
24
|
+
|
|
25
|
+
import 'mock_aws_s3_client.dart' show MockAwsS3Client, Store;
|
|
26
|
+
|
|
27
|
+
List<int> _b64urlDecode(String s) {
|
|
28
|
+
final normalized = s.replaceAll('-', '+').replaceAll('_', '/');
|
|
29
|
+
final padded = normalized.padRight(
|
|
30
|
+
normalized.length + (4 - normalized.length % 4) % 4,
|
|
31
|
+
'=',
|
|
32
|
+
);
|
|
33
|
+
return base64Decode(padded);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
String _wrapPem(String label, String b64) {
|
|
37
|
+
final chunks = <String>[];
|
|
38
|
+
for (var i = 0; i < b64.length; i += 64) {
|
|
39
|
+
chunks.add(b64.substring(i, i + 64 > b64.length ? b64.length : i + 64));
|
|
40
|
+
}
|
|
41
|
+
return '-----BEGIN $label-----\n${chunks.join('\n')}\n-----END $label-----\n';
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
String _encodePrivatePkcs1(RSAPrivateKey k, RSAPublicKey pub) {
|
|
45
|
+
final p = k.p!;
|
|
46
|
+
final q = k.q!;
|
|
47
|
+
final d = k.privateExponent!;
|
|
48
|
+
final dP = d % (p - BigInt.one);
|
|
49
|
+
final dQ = d % (q - BigInt.one);
|
|
50
|
+
final qInv = q.modInverse(p);
|
|
51
|
+
final seq = ASN1Sequence();
|
|
52
|
+
seq.add(ASN1Integer(BigInt.zero));
|
|
53
|
+
seq.add(ASN1Integer(k.modulus!));
|
|
54
|
+
seq.add(ASN1Integer(pub.exponent!));
|
|
55
|
+
seq.add(ASN1Integer(d));
|
|
56
|
+
seq.add(ASN1Integer(p));
|
|
57
|
+
seq.add(ASN1Integer(q));
|
|
58
|
+
seq.add(ASN1Integer(dP));
|
|
59
|
+
seq.add(ASN1Integer(dQ));
|
|
60
|
+
seq.add(ASN1Integer(qInv));
|
|
61
|
+
return _wrapPem('RSA PRIVATE KEY', base64.encode(seq.encodedBytes));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
String _encodePublic(RSAPublicKey pub) {
|
|
65
|
+
final keySeq = ASN1Sequence();
|
|
66
|
+
keySeq.add(ASN1Integer(pub.modulus!));
|
|
67
|
+
keySeq.add(ASN1Integer(pub.exponent!));
|
|
68
|
+
final spki = ASN1Sequence();
|
|
69
|
+
final alg = ASN1Sequence();
|
|
70
|
+
alg.add(ASN1ObjectIdentifier.fromComponents([1, 2, 840, 113549, 1, 1, 1]));
|
|
71
|
+
alg.add(ASN1Null());
|
|
72
|
+
spki.add(alg);
|
|
73
|
+
spki.add(ASN1BitString(keySeq.encodedBytes));
|
|
74
|
+
return _wrapPem('PUBLIC KEY', base64.encode(spki.encodedBytes));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
(String, String) _generateKeyPair() {
|
|
78
|
+
final rng = FortunaRandom();
|
|
79
|
+
final seedSource = Random.secure();
|
|
80
|
+
rng.seed(
|
|
81
|
+
KeyParameter(
|
|
82
|
+
Uint8List.fromList(
|
|
83
|
+
List<int>.generate(32, (_) => seedSource.nextInt(256)),
|
|
84
|
+
),
|
|
85
|
+
),
|
|
86
|
+
);
|
|
87
|
+
final kg = RSAKeyGenerator()
|
|
88
|
+
..init(
|
|
89
|
+
ParametersWithRandom(
|
|
90
|
+
RSAKeyGeneratorParameters(BigInt.from(65537), 2048, 12),
|
|
91
|
+
rng,
|
|
92
|
+
),
|
|
93
|
+
);
|
|
94
|
+
final pair = kg.generateKeyPair();
|
|
95
|
+
final priv = pair.privateKey as RSAPrivateKey;
|
|
96
|
+
final pub = pair.publicKey as RSAPublicKey;
|
|
97
|
+
return (_encodePrivatePkcs1(priv, pub), _encodePublic(pub));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
AwsLambdaEdgeStorageConfig _edgeConfig(
|
|
101
|
+
Store store,
|
|
102
|
+
String privatePem, {
|
|
103
|
+
String? publicBaseUrl,
|
|
104
|
+
Future<String> Function()? publicBaseUrlResolver,
|
|
105
|
+
}) =>
|
|
106
|
+
AwsLambdaEdgeStorageConfig(
|
|
107
|
+
bucketName: 'test-bucket',
|
|
108
|
+
region: 'us-east-1',
|
|
109
|
+
accessKeyId: 'ak',
|
|
110
|
+
secretAccessKey: 'sk',
|
|
111
|
+
keyPairId: 'KP123',
|
|
112
|
+
publicBaseUrl: publicBaseUrl,
|
|
113
|
+
publicBaseUrlResolver: publicBaseUrlResolver,
|
|
114
|
+
getPrivateKey: () async => privatePem,
|
|
115
|
+
clientFactory: (_) => MockAwsS3Client(store),
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
void main() {
|
|
119
|
+
group('aws cloudfront signed url', () {
|
|
120
|
+
final (privatePem, publicPem) = _generateKeyPair();
|
|
121
|
+
|
|
122
|
+
test('cloudfrontSignedUrl round-trips and verifies', () {
|
|
123
|
+
const url = 'https://cdn.example.com/bundles/x.zip';
|
|
124
|
+
final signed = cloudfrontSignedUrl(
|
|
125
|
+
url: url,
|
|
126
|
+
keyPairId: 'KP123',
|
|
127
|
+
privateKeyPem: privatePem,
|
|
128
|
+
dateLessThan: DateTime.now().add(const Duration(days: 365)),
|
|
129
|
+
);
|
|
130
|
+
expect(signed, contains('Policy='));
|
|
131
|
+
expect(signed, contains('Signature='));
|
|
132
|
+
expect(signed, contains('Key-Pair-Id=KP123'));
|
|
133
|
+
expect(
|
|
134
|
+
verifyCloudfrontSignedUrl(signedUrl: signed, publicKeyPem: publicPem),
|
|
135
|
+
isTrue,
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
final uri = Uri.parse(signed);
|
|
139
|
+
final policyJson = utf8.decode(_b64urlDecode(uri.queryParameters['Policy']!));
|
|
140
|
+
expect(policyJson, contains(url));
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test('s3LambdaEdgeStorage signs s3 download urls', () async {
|
|
144
|
+
final store = Store();
|
|
145
|
+
final plugin = s3LambdaEdgeStorage(
|
|
146
|
+
_edgeConfig(store, privatePem, publicBaseUrl: 'https://cdn.example.com'),
|
|
147
|
+
);
|
|
148
|
+
final RuntimeStorageProfile runtime = plugin.profiles.runtime!;
|
|
149
|
+
|
|
150
|
+
final res = await runtime.getDownloadUrl('s3://test-bucket/bundles/x.zip');
|
|
151
|
+
final fileUrl = res['fileUrl']!;
|
|
152
|
+
expect(fileUrl, contains('Key-Pair-Id=KP123'));
|
|
153
|
+
expect(
|
|
154
|
+
verifyCloudfrontSignedUrl(signedUrl: fileUrl, publicKeyPem: publicPem),
|
|
155
|
+
isTrue,
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
final uri = Uri.parse(fileUrl);
|
|
159
|
+
final policy = utf8.decode(_b64urlDecode(uri.queryParameters['Policy']!));
|
|
160
|
+
expect(policy, contains('https://cdn.example.com/bundles/x.zip'));
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test('s3LambdaEdgeStorage resolves publicBaseUrl via function', () async {
|
|
164
|
+
final store = Store();
|
|
165
|
+
final plugin = s3LambdaEdgeStorage(
|
|
166
|
+
_edgeConfig(
|
|
167
|
+
store,
|
|
168
|
+
privatePem,
|
|
169
|
+
publicBaseUrlResolver: () async => 'https://edge.acme.dev',
|
|
170
|
+
),
|
|
171
|
+
);
|
|
172
|
+
final runtime = plugin.profiles.runtime!;
|
|
173
|
+
final res = await runtime.getDownloadUrl('s3://test-bucket/a.zip');
|
|
174
|
+
expect(
|
|
175
|
+
verifyCloudfrontSignedUrl(
|
|
176
|
+
signedUrl: res['fileUrl']!,
|
|
177
|
+
publicKeyPem: publicPem,
|
|
178
|
+
),
|
|
179
|
+
isTrue,
|
|
180
|
+
);
|
|
181
|
+
expect(res['fileUrl'], contains('https://edge.acme.dev/a.zip'));
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test('plugin name carries WithCloudFrontSignedUrl suffix', () {
|
|
185
|
+
final store = Store();
|
|
186
|
+
final plugin = s3LambdaEdgeStorage(_edgeConfig(store, privatePem));
|
|
187
|
+
expect(plugin.name, 's3StorageWithCloudFrontSignedUrl');
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import 'dart:io' show Directory, File;
|
|
2
|
+
|
|
3
|
+
import 'package:flutter_patcher_aws/flutter_patcher_aws.dart' show s3Storage;
|
|
4
|
+
import 'package:flutter_patcher_plugin_core/flutter_patcher_plugin_core.dart'
|
|
5
|
+
show StoragePlugin;
|
|
6
|
+
import 'package:path/path.dart' show basename;
|
|
7
|
+
import 'package:test/test.dart';
|
|
8
|
+
|
|
9
|
+
import 'mock_aws_s3_client.dart' show Store, mockConfig;
|
|
10
|
+
|
|
11
|
+
void main() {
|
|
12
|
+
group('aws s3Storage', () {
|
|
13
|
+
late Store store;
|
|
14
|
+
late StoragePlugin plugin;
|
|
15
|
+
late String tmpFile;
|
|
16
|
+
late String outFile;
|
|
17
|
+
|
|
18
|
+
setUp(() {
|
|
19
|
+
store = Store();
|
|
20
|
+
plugin = s3Storage(mockConfig(store));
|
|
21
|
+
final dir = Directory.systemTemp.createTempSync('fp_s3_').path;
|
|
22
|
+
tmpFile = '$dir/bundle.zip';
|
|
23
|
+
File(tmpFile).writeAsBytesSync([1, 2, 3, 4]);
|
|
24
|
+
outFile = '$dir/out.zip';
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('plugin metadata', () {
|
|
28
|
+
expect(plugin.name, 's3Storage');
|
|
29
|
+
expect(plugin.supportedProtocol, 's3');
|
|
30
|
+
expect(plugin.profiles.hasNode, isTrue);
|
|
31
|
+
expect(plugin.profiles.hasRuntime, isTrue);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('node upload + exists + downloadFile + delete', () async {
|
|
35
|
+
final node = plugin.profiles.node!;
|
|
36
|
+
final res = await node.upload('v1', tmpFile);
|
|
37
|
+
expect(res['storageUri'], 's3://test-bucket/v1/${basename(tmpFile)}');
|
|
38
|
+
expect(await node.exists(res['storageUri']!), isTrue);
|
|
39
|
+
|
|
40
|
+
await node.downloadFile(res['storageUri']!, outFile);
|
|
41
|
+
expect(File(outFile).readAsBytesSync(), [1, 2, 3, 4]);
|
|
42
|
+
|
|
43
|
+
await node.delete(res['storageUri']!);
|
|
44
|
+
expect(await node.exists(res['storageUri']!), isFalse);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('runtime getDownloadUrl + readText (and missing → null)', () async {
|
|
48
|
+
final node = plugin.profiles.node!;
|
|
49
|
+
final runtime = plugin.profiles.runtime!;
|
|
50
|
+
|
|
51
|
+
final manifest = '${Directory.systemTemp.path}/fp_manifest.json';
|
|
52
|
+
File(manifest).writeAsStringSync('{"hello":1}');
|
|
53
|
+
final uploaded = await node.upload('manifests', manifest);
|
|
54
|
+
|
|
55
|
+
final url = await runtime.getDownloadUrl(uploaded['storageUri']!);
|
|
56
|
+
expect(url['fileUrl'], contains('manifests/'));
|
|
57
|
+
|
|
58
|
+
final text = await runtime.readText(uploaded['storageUri']!);
|
|
59
|
+
expect(text, '{"hello":1}');
|
|
60
|
+
|
|
61
|
+
expect(await runtime.readText('s3://test-bucket/missing'), isNull);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('listObjects filters by prefix', () async {
|
|
65
|
+
final node = plugin.profiles.node!;
|
|
66
|
+
await node.upload('a', tmpFile);
|
|
67
|
+
await node.upload('b', tmpFile);
|
|
68
|
+
|
|
69
|
+
expect(await node.listObjects(), hasLength(2));
|
|
70
|
+
expect(await node.listObjects('a/'), hasLength(1));
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import 'dart:convert' show utf8;
|
|
2
|
+
|
|
3
|
+
import 'package:flutter_patcher_plugin_core/flutter_patcher_plugin_core.dart'
|
|
4
|
+
show StorageObject;
|
|
5
|
+
|
|
6
|
+
import 'package:flutter_patcher_aws/flutter_patcher_aws.dart'
|
|
7
|
+
show AwsS3ClientLike, AwsS3StorageConfig;
|
|
8
|
+
|
|
9
|
+
/// In-memory store backing [MockAwsS3Client].
|
|
10
|
+
class Store {
|
|
11
|
+
final Map<String, List<int>> objects = {};
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/// Mock AWS S3 client that stores objects in memory.
|
|
15
|
+
class MockAwsS3Client implements AwsS3ClientLike {
|
|
16
|
+
MockAwsS3Client(this.store, {this.bucketName = 'bucket'});
|
|
17
|
+
|
|
18
|
+
final Store store;
|
|
19
|
+
final String bucketName;
|
|
20
|
+
|
|
21
|
+
@override
|
|
22
|
+
Future<void> deleteObject(String key) async => store.objects.remove(key);
|
|
23
|
+
|
|
24
|
+
@override
|
|
25
|
+
Future<void> deleteObjects(List<String> keys) async {
|
|
26
|
+
for (final key in keys) {
|
|
27
|
+
store.objects.remove(key);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
@override
|
|
32
|
+
Future<bool> headObject(String key) async =>
|
|
33
|
+
store.objects.containsKey(key);
|
|
34
|
+
|
|
35
|
+
@override
|
|
36
|
+
Future<List<int>> getObjectAsBytes(String key) async {
|
|
37
|
+
final bytes = store.objects[key];
|
|
38
|
+
if (bytes == null) throw Exception('NoSuchKey: $key');
|
|
39
|
+
return bytes;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
@override
|
|
43
|
+
Future<String> getObjectAsString(String key) async =>
|
|
44
|
+
utf8.decode(await getObjectAsBytes(key));
|
|
45
|
+
|
|
46
|
+
@override
|
|
47
|
+
Future<String> getPresignedUrl(String key, {int expiresIn = 3600}) async =>
|
|
48
|
+
'https://$bucketName.s3.us-east-1.amazonaws.com/$bucketName/$key'
|
|
49
|
+
'?X-Amz-Signature=mock&X-Amz-Expires=$expiresIn';
|
|
50
|
+
|
|
51
|
+
@override
|
|
52
|
+
Future<List<StorageObject>> listObjects([String? prefix]) async {
|
|
53
|
+
final p = prefix ?? '';
|
|
54
|
+
return store.objects.keys
|
|
55
|
+
.where((k) => k.startsWith(p))
|
|
56
|
+
.map(
|
|
57
|
+
(k) => StorageObject(
|
|
58
|
+
key: k,
|
|
59
|
+
storageUri: 's3://$bucketName/$k',
|
|
60
|
+
size: store.objects[k]!.length,
|
|
61
|
+
),
|
|
62
|
+
)
|
|
63
|
+
.toList();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
@override
|
|
67
|
+
Future<void> putObject(
|
|
68
|
+
String key,
|
|
69
|
+
List<int> body,
|
|
70
|
+
String contentType,
|
|
71
|
+
) async {
|
|
72
|
+
store.objects[key] = body;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/// Build an [AwsS3StorageConfig] wired to an in-memory [Store].
|
|
77
|
+
AwsS3StorageConfig mockConfig(Store store) => AwsS3StorageConfig(
|
|
78
|
+
bucketName: 'test-bucket',
|
|
79
|
+
region: 'us-east-1',
|
|
80
|
+
accessKeyId: 'test-access-key',
|
|
81
|
+
secretAccessKey: 'test-secret',
|
|
82
|
+
clientFactory: (_) => MockAwsS3Client(store),
|
|
83
|
+
);
|