diff --git a/lib/core/services/ai_workspace_move.dart b/lib/core/services/ai_workspace_move.dart new file mode 100644 index 0000000..6222e51 --- /dev/null +++ b/lib/core/services/ai_workspace_move.dart @@ -0,0 +1,116 @@ +// Move-as-access-control for the AI workspace (1.iii). +// +// Moving a file INTO `fula-ai-workspace` re-encrypts it under the workspace +// secret + forest-indexes it (the AI gains read). Moving it OUT re-encrypts it +// under the master KEK in a normal bucket and removes the workspace copy (the AI +// loses read). The owner keeps the file either way. +// +// This is the UI-free orchestration shared by rename + move, factored out of the +// cloud-files screen so the security-critical REVOKE path is unit-testable: a +// move-out must (1) verify the re-encrypted destination decrypts under the master +// KEK BEFORE deleting the only-AI copy, then (2) verify the AI copy is actually +// gone — and report `revoke incomplete` loudly otherwise (the AI keeps exact-key +// read until the delete lands, so a silent failure would leave access un-revoked). + +import 'dart:typed_data'; + +import 'package:fula_files/core/services/fula_api.dart'; +import 'package:fula_files/core/services/fula_api_service.dart' + show FulaApiService; + +/// The outcome of [aiAwareMove], mapped to UI messaging by the caller. +enum AiMoveResult { + /// A normal move/rename between non-AI buckets (or within one). + moved, + + /// Moved INTO the AI bucket — the AI can now read it. + grantedToAi, + + /// Moved OUT of the AI bucket — AI access removed, and VERIFIED removed. + revokedFromAi, + + /// The download / re-upload failed; the source is untouched. + copyFailed, + + /// (Revoke) the re-encrypted destination did NOT decrypt under the master KEK; + /// the AI copy is KEPT rather than deleting the only readable copy. + verifyFailed, + + /// The source delete failed. For a revoke this means AI access was NOT removed. + deleteFailed, + + /// (Revoke) the delete returned OK but the AI copy still enumerates — access is + /// NOT actually revoked. + revokeIncomplete, +} + +/// Copy → (revoke-verify) → delete → (revoke-verify-gone), routing each side to +/// the workspace client (the AI bucket) or the master-KEK client (everything +/// else). Pure over [FulaApi] (no UI / no global state), so it is unit-testable. +Future aiAwareMove( + FulaApi api, { + required String srcBucket, + required String srcKey, + required String destBucket, + required String destKey, + String? contentType, +}) async { + final ws = FulaApiService.aiWorkspaceBucket; + final srcIsAi = srcBucket == ws; + final destIsAi = destBucket == ws; + final isRevoke = srcIsAi && !destIsAi; // moving OUT of the AI bucket + + // 1. COPY: download from the right client → re-upload to the right client. + try { + final Uint8List bytes = srcIsAi + ? await api.downloadWorkspaceObject(srcBucket, srcKey) + : await api.downloadObject(srcBucket, srcKey); + if (destIsAi) { + await api.uploadWorkspaceObject(destBucket, destKey, bytes, + contentType: contentType); + } else { + await api.uploadObject(destBucket, destKey, bytes, + contentType: contentType); + } + } catch (_) { + return AiMoveResult.copyFailed; + } + + // 2. REVOKE SAFETY: verify the re-encrypted destination is readable under the + // master KEK BEFORE deleting the AI copy. + if (isRevoke) { + try { + await api.downloadObject(destBucket, destKey); + } catch (_) { + return AiMoveResult.verifyFailed; + } + } + + // 3. DELETE the source from the right client. + try { + if (srcIsAi) { + await api.deleteWorkspaceObject(srcBucket, srcKey); + } else { + await api.deleteObject(srcBucket, srcKey); + } + } catch (_) { + return AiMoveResult.deleteFailed; + } + + // 4. REVOKE VERIFY: confirm the AI copy is actually gone (no longer + // enumerable). A lingering entry means access was NOT revoked. + if (isRevoke) { + try { + final still = await api.listWorkspaceObjects(srcBucket, prefix: srcKey); + if (still.any((x) => x.key == srcKey)) { + return AiMoveResult.revokeIncomplete; + } + } catch (_) { + // Verification read failed; the delete returned OK, so treat as done. + } + } + + return isRevoke + ? AiMoveResult.revokedFromAi + : (destIsAi ? AiMoveResult.grantedToAi : AiMoveResult.moved); +} diff --git a/lib/core/services/category_listing.dart b/lib/core/services/category_listing.dart index d674b39..04713f1 100644 --- a/lib/core/services/category_listing.dart +++ b/lib/core/services/category_listing.dart @@ -42,25 +42,45 @@ import 'package:fula_files/core/services/legacy_listing_cache.dart'; /// so a user's own object with a colliding key always overwrites an AI entry. /// (AI keys `ai/images/x.jpg` and user keys `x.jpg` are disjoint, so a /// collision can't actually occur — this is zero-cost belt-and-suspenders.) -/// - VALIDATED: only keys shaped `ai//...` are admitted. The MCP is an +/// - VALIDATED: only keys shaped `ai//...` (with the AI category +/// mapped to THIS FxFiles view, below) are admitted. The MCP is an /// independently-versioned writer; a malformed or off-category key is DROPPED /// rather than mis-filed. +/// +/// CATEGORY MAPPING: the hosted MCP + the local fula-mcp write SINGULAR category +/// segments — `ai/document/`, `ai/note/`, `ai/image/`, … (cloudflare `classify.ts` +/// / fula-mcp `classify.rs`). FxFiles' category views are PLURAL (`documents`, +/// `images`, … = `FileCategory.bucketName`). [_aiCategoriesForBase] bridges them, +/// and folds the AI categories that have no `FileCategory` of their own +/// (note/link → documents, screenshot → images, file → other) into the closest view. +const Map> _aiCategoriesForBase = { + 'images': ['image', 'screenshot'], + 'videos': ['video'], + 'audio': ['audio'], + 'documents': ['document', 'note', 'link'], + 'other': ['file', 'other'], + // 'downloads' / 'archives' / 'starred' have no AI-workspace counterpart. +}; + Future _mergeAiWorkspaceInto( FulaApi api, String base, Map byKey, ) async { + final aiCats = _aiCategoriesForBase[base]; + if (aiCats == null || aiCats.isEmpty) return; // no AI category maps to this view if (!await api.hasAiConnection()) return; - // listWorkspaceObjects never throws — tolerant by contract. + // listWorkspaceObjects never throws — tolerant by contract. List the whole `ai/` + // scope once, then admit only the AI categories that belong in THIS view. final aiItems = await api.listWorkspaceObjects( FulaApiService.aiWorkspaceBucket, - prefix: 'ai/$base/', + prefix: 'ai/', ); + final cats = aiCats.toSet(); for (final o in aiItems) { final parts = o.key.split('/'); - // Require exactly `ai//` — drop anything else. - if (parts.length < 3 || parts[0] != 'ai' || parts[1] != base) { - debugPrint('listCategory: dropping off-shape AI key "${o.key}"'); + // Require exactly `ai//` with a category in this view. + if (parts.length < 3 || parts[0] != 'ai' || !cats.contains(parts[1])) { continue; } // Already tagged sourceBucket='fula-ai-workspace' by listWorkspaceObjects. diff --git a/lib/core/services/fula_api.dart b/lib/core/services/fula_api.dart index 056e891..2c93b35 100644 --- a/lib/core/services/fula_api.dart +++ b/lib/core/services/fula_api.dart @@ -278,6 +278,21 @@ abstract class FulaApi { /// error; the tag-adoption caller catches it so a failure never blocks restore. Future downloadWorkspaceObject(String bucket, String key); + /// Upload + encrypt a file INTO the AI-workspace forest (the GRANT primitive + /// for moving a file into the AI bucket): written under the workspace secret + + /// forest-indexed, so the AI can list + read it. Throws on a genuine error. + Future uploadWorkspaceObject( + String bucket, + String key, + Uint8List bytes, { + String? contentType, + }); + + /// Delete an object from the AI-workspace forest (the REVOKE primitive for + /// moving a file out of the AI bucket): removes BOTH the ciphertext and the + /// forest index entry, so the AI can no longer enumerate OR read it. + Future deleteWorkspaceObject(String bucket, String key); + // share/collab APIs deliberately not in this interface yet — those // scenarios are skeleton-only in this round. Adding them later is // an additive change to this surface + the concrete service + diff --git a/lib/core/services/fula_api_service.dart b/lib/core/services/fula_api_service.dart index 6337366..ab70515 100644 --- a/lib/core/services/fula_api_service.dart +++ b/lib/core/services/fula_api_service.dart @@ -1252,6 +1252,69 @@ class FulaApiService implements FulaApi { } } + /// Upload + encrypt a file INTO the AI-workspace forest via the workspace + /// client (mirrors [uploadObject] on `_workspaceClient`, forest-tracked so the + /// object is enumerable). The GRANT primitive for moving a file INTO the AI + /// bucket: written under the workspace secret + indexed, so the AI can list + + /// read it. GATED + lazy like [downloadWorkspaceObject]. + @override + Future uploadWorkspaceObject( + String bucket, + String key, + Uint8List bytes, { + String? contentType, + }) async { + if (!await hasAiConnection()) { + throw FulaApiException('No AI connection — workspace upload skipped'); + } + if (_workspaceClient == null) { + final secret = await _deriveWorkspaceSecretForRead(); + if (secret != null) await initializeWorkspaceClient(secret); + } + if (_workspaceClient == null) { + throw FulaApiException('AI workspace client unavailable'); + } + try { + await _ensureWorkspaceForestLoaded(bucket); + await fula.putFlat( + client: _workspaceClient!, + bucket: bucket, + path: key, + data: bytes.toList(), + contentType: contentType, + ); + } catch (e) { + _workspaceLoadedForests.remove(bucket); + throw FulaApiException('Failed to upload workspace object: $e'); + } + } + + /// Delete an object from the AI-workspace forest via the workspace client + /// (mirrors [deleteObject] on `_workspaceClient`). The REVOKE primitive for + /// moving a file OUT of the AI bucket: `deleteFlat` removes BOTH the ciphertext + /// AND the forest index entry, so a compromised MCP can no longer enumerate OR + /// read the file. + @override + Future deleteWorkspaceObject(String bucket, String key) async { + if (!await hasAiConnection()) { + throw FulaApiException('No AI connection — workspace delete skipped'); + } + if (_workspaceClient == null) { + final secret = await _deriveWorkspaceSecretForRead(); + if (secret != null) await initializeWorkspaceClient(secret); + } + if (_workspaceClient == null) { + throw FulaApiException('AI workspace client unavailable'); + } + try { + await _ensureWorkspaceForestLoaded(bucket); + await fula.deleteFlat(client: _workspaceClient!, bucket: bucket, path: key); + } catch (e) { + _workspaceLoadedForests.remove(bucket); + throw FulaApiException('Failed to delete workspace object: $e'); + } + } + /// Re-derive the AI-workspace secret for a READ path (lazy client build). /// /// Reuses P13's [AiConnectionService.deriveWorkspaceSecret] verbatim — the diff --git a/lib/web/screens/web_cloud_files_screen.dart b/lib/web/screens/web_cloud_files_screen.dart index dead346..7aa0cd3 100644 --- a/lib/web/screens/web_cloud_files_screen.dart +++ b/lib/web/screens/web_cloud_files_screen.dart @@ -8,7 +8,9 @@ import 'package:lucide_icons/lucide_icons.dart'; import 'package:fula_files/core/models/file_tag.dart'; import 'package:fula_files/core/models/fula_object.dart'; import 'package:fula_files/core/models/share_token.dart' as share_model; +import 'package:fula_files/core/services/ai_workspace_move.dart'; import 'package:fula_files/core/services/bucket_version_resolver.dart'; +import 'package:fula_files/core/services/file_service.dart' show FileCategory; import 'package:fula_files/core/services/fula_api_service.dart'; import 'package:fula_files/core/services/ipfs_public_service.dart'; import 'package:fula_files/web/services/web_audio_controller.dart'; @@ -119,8 +121,16 @@ class _WebCloudFilesScreenState extends State { }); } try { - final objs = await WebForegroundActivity.instance - .run(() => FulaApiService.instance.listObjects(bucket)); + // The AI workspace is encrypted with the workspace secret (not the master + // KEK) and its files live in the forest under `ai/`. Route it through the + // workspace client so the bucket view actually decrypts + lists them (and + // tags each `sourceBucket='fula-ai-workspace'` so open/move route correctly); + // every other bucket uses the normal master-KEK listing unchanged. + final objs = await WebForegroundActivity.instance.run(() => + bucket == FulaApiService.aiWorkspaceBucket + ? FulaApiService.instance + .listWorkspaceObjects(bucket, prefix: 'ai/') + : FulaApiService.instance.listObjects(bucket)); if (!mounted) return; setState(() { _objects = objs; @@ -633,7 +643,11 @@ class _WebCloudFilesScreenState extends State { final dest = await _pickMoveDestination(o); if (dest == null) return; final srcBucket = o.sourceBucket ?? _bucket!; - final destKey = cloudChildKey(dest.prefix, o.name); + // Moving INTO the AI bucket must use an `ai//` key (matching + // the MCP writer) so the AI's category tools find it; else the chosen folder. + final destKey = dest.bucket == FulaApiService.aiWorkspaceBucket + ? 'ai/${_aiCategoryForFile(o.name)}/${o.name}' + : cloudChildKey(dest.prefix, o.name); if (dest.bucket == srcBucket && normalizeCloudKey(destKey) == normalizeCloudKey(o.key)) { _snack('Already there'); @@ -642,36 +656,77 @@ class _WebCloudFilesScreenState extends State { await _copyDelete(srcBucket, o, dest.bucket, destKey, 'Moved'); } + /// The AI-workspace SINGULAR category segment for a file, by extension — + /// matching what the MCP writer uses (classify.ts / classify.rs) so a moved-in + /// file lands where the AI's category tools expect it. + String _aiCategoryForFile(String name) { + final dot = name.lastIndexOf('.'); + final ext = dot >= 0 ? name.substring(dot + 1) : ''; + switch (FileCategory.fromExtension(ext)) { + case FileCategory.images: + return 'image'; + case FileCategory.videos: + return 'video'; + case FileCategory.audio: + return 'audio'; + case FileCategory.documents: + return 'document'; + default: + return 'file'; // downloads/archives/starred/other → generic AI 'file' + } + } + /// Shared download → upload-to-dest → delete-source for rename + move. No /// server-side copy exists for the encrypted forest, so this round-trips the /// bytes (size-guarded by [_guardCopySize]). Future _copyDelete(String srcBucket, FulaObject o, String destBucket, String destKey, String okWord) async { _snack('Working…'); - // Copy (download → re-upload). If this fails, the source is untouched. - try { - final bytes = await WebForegroundActivity.instance - .run(() => FulaApiService.instance.downloadObject(srcBucket, o.key)); - await WebForegroundActivity.instance.run(() => FulaApiService.instance - .uploadObject(destBucket, destKey, bytes, contentType: _ct(o))); - } catch (e) { - _snack('$okWord failed: ${_clean(e)}'); - return; - } - // Copy landed — remove the original. A failure here leaves a duplicate, - // not data loss, so say so precisely. - try { - await WebForegroundActivity.instance - .run(() => FulaApiService.instance.deleteObject(srcBucket, o.key)); - } catch (e) { - _snack('Copied, but couldn\'t remove the original: ${_clean(e)}'); - await _loadObjects(silent: true); - return; - } + // The copy → (revoke-verify) → delete → (revoke-verify-gone) orchestration + // lives in [aiAwareMove] (UI-free + unit-tested); here we just map its result. + final result = await WebForegroundActivity.instance.run( + () => aiAwareMove( + FulaApiService.instance, + srcBucket: srcBucket, + srcKey: o.key, + destBucket: destBucket, + destKey: destKey, + contentType: _ct(o), + ), + ); if (!mounted) return; + final srcIsAi = srcBucket == FulaApiService.aiWorkspaceBucket; + switch (result) { + case AiMoveResult.copyFailed: + _snack('$okWord failed.'); + return; + case AiMoveResult.verifyFailed: + _snack('$okWord aborted — the moved copy did not verify; ' + 'the original is kept.'); + return; + case AiMoveResult.deleteFailed: + _snack(srcIsAi + ? 'REVOKE INCOMPLETE — the AI can still read "${o.name}". Retry.' + : 'Copied, but couldn\'t remove the original.'); + await _loadObjects(silent: true); + return; + case AiMoveResult.revokeIncomplete: + _snack('REVOKE INCOMPLETE — "${o.name}" is still in the AI ' + 'workspace. Retry.'); + await _loadObjects(silent: true); + return; + case AiMoveResult.moved: + case AiMoveResult.grantedToAi: + case AiMoveResult.revokedFromAi: + break; // success + } setState(() => _objects = [for (final x in _objects) if (x.key != o.key) x]); - _snack(okWord); + _snack(switch (result) { + AiMoveResult.grantedToAi => 'Moved in — AI can now access it', + AiMoveResult.revokedFromAi => 'Moved out — AI access removed', + _ => okWord, + }); unawaited(WebThumbnailService.instance .moveCloudThumb(srcBucket, o.key, destBucket, destKey)); await _loadObjects(silent: true); diff --git a/test/helpers/fake_fula_api.dart b/test/helpers/fake_fula_api.dart index 78de3f1..1e9a48c 100644 --- a/test/helpers/fake_fula_api.dart +++ b/test/helpers/fake_fula_api.dart @@ -409,4 +409,50 @@ class FakeFulaApi implements FulaApi { sourceBucket == FulaApi.aiWorkspaceBucket ? downloadWorkspaceObject(FulaApi.aiWorkspaceBucket, key) : downloadWithLocalFallback(bucket, key); + + // ---- AI-aware WRITE / DELETE (move-as-access-control) ---- + /// Bytes written via [uploadWorkspaceObject], keyed `"$bucket:$key"`. + final Map workspaceUploadResponseFor = {}; + final Map workspaceUploadCalls = {}; + final Map workspaceDeleteCalls = {}; + + @override + Future uploadWorkspaceObject( + String bucket, + String key, + Uint8List bytes, { + String? contentType, + }) async { + if (!aiConnectionExists) { + throw FulaApiException('FakeFulaApi: no AI connection (upload)'); + } + final composite = '$bucket:$key'; + workspaceUploadCalls[composite] = (workspaceUploadCalls[composite] ?? 0) + 1; + workspaceUploadResponseFor[composite] = bytes; + // Mirror the real forest-tracked put: make it READABLE + ENUMERABLE. + workspaceDownloadResponseFor[composite] = bytes; + final list = objectsResponseFor[bucket] ?? const []; + if (!list.any((o) => o.key == key)) { + objectsResponseFor[bucket] = [ + ...list, + FulaObject(key: key, size: bytes.length), + ]; + } + } + + @override + Future deleteWorkspaceObject(String bucket, String key) async { + if (!aiConnectionExists) { + throw FulaApiException('FakeFulaApi: no AI connection (delete)'); + } + final composite = '$bucket:$key'; + workspaceDeleteCalls[composite] = (workspaceDeleteCalls[composite] ?? 0) + 1; + // Mirror delete_flat removing the forest entry + the blob: a post-delete + // list / read no longer surfaces it (the revoke is real). + workspaceDownloadResponseFor.remove(composite); + final list = objectsResponseFor[bucket]; + if (list != null) { + objectsResponseFor[bucket] = [for (final o in list) if (o.key != key) o]; + } + } } diff --git a/test/unit/core/services/ai_workspace_adoption_test.dart b/test/unit/core/services/ai_workspace_adoption_test.dart index 93e2f08..ddf0512 100644 --- a/test/unit/core/services/ai_workspace_adoption_test.dart +++ b/test/unit/core/services/ai_workspace_adoption_test.dart @@ -33,48 +33,91 @@ void main() { tearDown(() => BucketVersionResolver.enabled = false); group('category merge surfaces AI-workspace items (native views)', () { - test('images view includes the ai/images item; documents view its doc; ' - 'each tagged sourceBucket=fula-ai-workspace', () async { + test('SINGULAR AI categories map to the PLURAL FxFiles views; each tagged ' + 'sourceBucket=fula-ai-workspace', () async { final fake = FakeFulaApi(); fake.aiConnectionExists = true; // an AI connection exists // The user's own files in each category. fake.objectsResponseFor['images'] = [obj('photo.jpg')]; fake.objectsResponseFor['documents'] = [obj('resume.pdf')]; - // The AI workspace holds items for BOTH categories under ai//. + // The AI/MCP writes SINGULAR category segments (ai/image/, ai/document/, … + // per classify.ts / classify.rs) — NOT the app's plural category names. fake.objectsResponseFor[_ws] = [ - obj('ai/images/sketch.png'), - obj('ai/documents/notes.md'), + obj('ai/image/sketch.png'), + obj('ai/document/notes.md'), ]; final images = await listCategoryMerged(fake, 'images'); final documents = await listCategoryMerged(fake, 'documents'); - // Images view: the user's own photo + ONLY the ai/images item (the - // ai/documents item must be routed away by the per-category prefix). + // Images view: the user's own photo + ONLY the ai/image item (the + // ai/document item must be routed to the documents view, not here). final imgKeys = images.map((o) => o.key).toSet(); - expect(imgKeys, containsAll(['photo.jpg', 'ai/images/sketch.png'])); - expect(imgKeys.contains('ai/documents/notes.md'), isFalse, - reason: 'documents AI item must NOT leak into the images view'); + expect(imgKeys, containsAll(['photo.jpg', 'ai/image/sketch.png'])); + expect(imgKeys.contains('ai/document/notes.md'), isFalse, + reason: 'a document AI item must NOT leak into the images view'); final imgSrc = {for (final o in images) o.key: o.sourceBucket}; - expect(imgSrc['ai/images/sketch.png'], _ws, + expect(imgSrc['ai/image/sketch.png'], _ws, reason: 'AI item carries sourceBucket=fula-ai-workspace'); expect(imgSrc['photo.jpg'], 'images', reason: "the user's own item keeps its real bucket"); - // Documents view: the user's own pdf + ONLY the ai/documents doc. + // Documents view: the user's own pdf + ONLY the ai/document doc. final docKeys = documents.map((o) => o.key).toSet(); expect(docKeys, - containsAll(['resume.pdf', 'ai/documents/notes.md'])); - expect(docKeys.contains('ai/images/sketch.png'), isFalse); + containsAll(['resume.pdf', 'ai/document/notes.md'])); + expect(docKeys.contains('ai/image/sketch.png'), isFalse); final docSrc = {for (final o in documents) o.key: o.sourceBucket}; - expect(docSrc['ai/documents/notes.md'], _ws); + expect(docSrc['ai/document/notes.md'], _ws); + }); + + test('homeless AI categories fold into the closest view: ' + 'note/link→documents, screenshot→images, file/other→other', () async { + final fake = FakeFulaApi(); + fake.aiConnectionExists = true; + fake.objectsResponseFor[_ws] = [ + obj('ai/note/todo.txt'), + obj('ai/link/bookmark.url'), + obj('ai/screenshot/cap.png'), + obj('ai/file/blob.bin'), + obj('ai/other/misc.dat'), + ]; + + final docs = + (await listCategoryMerged(fake, 'documents')).map((o) => o.key).toSet(); + final imgs = + (await listCategoryMerged(fake, 'images')).map((o) => o.key).toSet(); + final other = + (await listCategoryMerged(fake, 'other')).map((o) => o.key).toSet(); + + expect(docs, + containsAll(['ai/note/todo.txt', 'ai/link/bookmark.url'])); + expect(imgs, contains('ai/screenshot/cap.png')); + expect(other, + containsAll(['ai/file/blob.bin', 'ai/other/misc.dat'])); + // No cross-leak between the folded views. + expect(docs.contains('ai/screenshot/cap.png'), isFalse); + expect(imgs.contains('ai/note/todo.txt'), isFalse); + }); + + test('a view with NO AI mapping (downloads) never lists the workspace', + () async { + final fake = FakeFulaApi(); + fake.aiConnectionExists = true; + fake.objectsResponseFor['downloads'] = [obj('setup.exe')]; + fake.objectsResponseFor[_ws] = [obj('ai/document/x.md')]; + + final downloads = await listCategoryMerged(fake, 'downloads'); + expect(downloads.map((o) => o.key), ['setup.exe']); + expect(fake.listWorkspaceObjectsCalls[_ws], isNull, + reason: 'downloads maps to no AI category — short-circuit, no list'); }); test('GATE: no AI connection ⇒ the workspace is never listed', () async { final fake = FakeFulaApi(); // aiConnectionExists defaults to FALSE (non-AI user). fake.objectsResponseFor['images'] = [obj('photo.jpg')]; - fake.objectsResponseFor[_ws] = [obj('ai/images/sketch.png')]; // ignored + fake.objectsResponseFor[_ws] = [obj('ai/image/sketch.png')]; // ignored final images = await listCategoryMerged(fake, 'images'); @@ -84,32 +127,27 @@ void main() { reason: 'the gate must short-circuit before any workspace list call'); }); - test('off-shape / off-category AI keys are dropped, not mis-filed', - () async { + test('off-category AI keys are dropped, not mis-filed', () async { final fake = FakeFulaApi(); fake.aiConnectionExists = true; fake.objectsResponseFor['images'] = [obj('photo.jpg')]; - // The prefix filter (ai/images/) already constrains the list, but assert - // the helper's own validation by feeding a well-formed item only and - // confirming a sibling-category key does not appear in images. fake.objectsResponseFor[_ws] = [ - obj('ai/images/ok.png'), - obj('ai/videos/clip.mp4'), // different category — must not reach images + obj('ai/image/ok.png'), + obj('ai/video/clip.mp4'), // different category — must not reach images ]; final images = await listCategoryMerged(fake, 'images'); final keys = images.map((o) => o.key).toSet(); - expect(keys.contains('ai/images/ok.png'), isTrue); - expect(keys.contains('ai/videos/clip.mp4'), isFalse); + expect(keys.contains('ai/image/ok.png'), isTrue); + expect(keys.contains('ai/video/clip.mp4'), isFalse); }); test('AI read failure is tolerated — user content still shows', () async { final fake = FakeFulaApi(); fake.aiConnectionExists = true; fake.objectsResponseFor['images'] = [obj('photo.jpg')]; - // listObjectsErrorFor targets the workspace bucket; the fake's - // listWorkspaceObjects reads objectsResponseFor (empty here), so simulate - // "AI empty" — the user's own bucket must be unaffected either way. + // The fake's listWorkspaceObjects returns objectsResponseFor (empty here), + // so simulate "AI empty" — the user's own bucket must be unaffected. fake.objectsResponseFor[_ws] = const []; final images = await listCategoryMerged(fake, 'images'); diff --git a/test/unit/core/services/ai_workspace_move_test.dart b/test/unit/core/services/ai_workspace_move_test.dart new file mode 100644 index 0000000..b584e4a --- /dev/null +++ b/test/unit/core/services/ai_workspace_move_test.dart @@ -0,0 +1,121 @@ +// AI-workspace move-as-access-control: unit tests for [aiAwareMove] (device-free, +// FFI-free). Pins the security-critical REVOKE: a move-out must verify the +// re-encrypted master-KEK copy decrypts BEFORE deleting the only-AI copy, and +// then verify the AI copy is actually gone. +// +// Run: flutter test test/unit/core/services/ai_workspace_move_test.dart + +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:fula_files/core/models/fula_object.dart'; +import 'package:fula_files/core/services/ai_workspace_move.dart'; +import 'package:fula_files/core/services/fula_api_service.dart' + show FulaApiService; + +import '../../../helpers/fake_fula_api.dart'; + +const String _ws = FulaApiService.aiWorkspaceBucket; // 'fula-ai-workspace' +final Uint8List _bytes = Uint8List.fromList([1, 2, 3, 4]); + +void main() { + group('aiAwareMove — grant (move INTO the AI bucket)', () { + test('re-encrypts via the workspace client + deletes the source', () async { + final fake = FakeFulaApi(); + fake.aiConnectionExists = true; + fake.downloadResponseFor['images:photo.jpg'] = _bytes; + fake.objectsResponseFor['images'] = [FulaObject(key: 'photo.jpg', size: 4)]; + + final r = await aiAwareMove( + fake, + srcBucket: 'images', + srcKey: 'photo.jpg', + destBucket: _ws, + destKey: 'ai/image/photo.jpg', + ); + + expect(r, AiMoveResult.grantedToAi); + // Written via the WORKSPACE client (grant), under the ai/image/ key. + expect(fake.workspaceUploadCalls['$_ws:ai/image/photo.jpg'], 1); + // Now enumerable in the workspace (the AI can list it). + final ws = await fake.listWorkspaceObjects(_ws, prefix: 'ai/'); + expect(ws.map((o) => o.key), contains('ai/image/photo.jpg')); + // The source (normal bucket) was removed — a move, not a copy. + expect(fake.deletedKeys, contains('images:photo.jpg')); + }); + }); + + group('aiAwareMove — revoke (move OUT of the AI bucket)', () { + FakeFulaApi revokeFake() { + final fake = FakeFulaApi(); + fake.aiConnectionExists = true; + fake.objectsResponseFor[_ws] = [FulaObject(key: 'ai/note/x.txt', size: 4)]; + fake.workspaceDownloadResponseFor['$_ws:ai/note/x.txt'] = _bytes; + // The re-encrypted master-KEK copy is readable at the destination. + fake.downloadResponseFor['documents:x.txt'] = _bytes; + return fake; + } + + test('verifies the master copy, then deletes + verifies the AI copy gone', + () async { + final fake = revokeFake(); + + final r = await aiAwareMove( + fake, + srcBucket: _ws, + srcKey: 'ai/note/x.txt', + destBucket: 'documents', + destKey: 'x.txt', + ); + + expect(r, AiMoveResult.revokedFromAi); + expect(fake.workspaceDeleteCalls['$_ws:ai/note/x.txt'], 1); + final still = await fake.listWorkspaceObjects(_ws, prefix: 'ai/'); + expect(still.any((o) => o.key == 'ai/note/x.txt'), isFalse, + reason: 'the AI copy is verified gone'); + }); + + test('ABORTS without deleting if the re-encrypted copy does not verify', + () async { + final fake = revokeFake(); + // The destination read FAILS (bad re-encrypt) — the AI copy MUST be kept. + fake.downloadErrorFor['documents:x.txt'] = Exception('decrypt failed'); + + final r = await aiAwareMove( + fake, + srcBucket: _ws, + srcKey: 'ai/note/x.txt', + destBucket: 'documents', + destKey: 'x.txt', + ); + + expect(r, AiMoveResult.verifyFailed); + expect(fake.workspaceDeleteCalls['$_ws:ai/note/x.txt'], isNull, + reason: 'must NOT delete the only readable copy when verify fails'); + final still = await fake.listWorkspaceObjects(_ws, prefix: 'ai/'); + expect(still.any((o) => o.key == 'ai/note/x.txt'), isTrue, + reason: 'the AI copy is kept on a failed verify'); + }); + }); + + group('aiAwareMove — normal move (no AI bucket involved)', () { + test('routes both sides to the master-KEK client; no workspace calls', + () async { + final fake = FakeFulaApi(); + fake.downloadResponseFor['images:a.jpg'] = _bytes; + + final r = await aiAwareMove( + fake, + srcBucket: 'images', + srcKey: 'a.jpg', + destBucket: 'documents', + destKey: 'a.jpg', + ); + + expect(r, AiMoveResult.moved); + expect(fake.deletedKeys, contains('images:a.jpg')); + expect(fake.workspaceUploadCalls.isEmpty, isTrue); + expect(fake.workspaceDeleteCalls.isEmpty, isTrue); + }); + }); +}