test(12.1-01): add failing four-provider contract suite and fixtures
- Create test_cloud_provider_contract.py with parametrized suite for Nextcloud, WebDAV, Google Drive, OneDrive - Assert canonical (connection_id, user_id, parent_ref=None, page_token=None) signature, CloudListing return type, trusted caller identity propagation, metadata normalization, pagination completeness, and forbidden-operation spies - Add synthetic credential-free fixtures: nextcloud_root.xml, webdav_root.xml, google_drive_pages.json, onedrive_pages.json - 9 tests fail for nextcloud (expected RED state — incompatible list_folder override) - All webdav, google_drive, onedrive contract tests pass
This commit is contained in:
@@ -0,0 +1,90 @@
|
|||||||
|
{
|
||||||
|
"_comment": "Synthetic Google Drive fixture — no real credentials, tokens, or user data.",
|
||||||
|
|
||||||
|
"root_page1": {
|
||||||
|
"nextPageToken": "page2token_synthetic",
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"id": "synth_folder_a1b2c3",
|
||||||
|
"name": "Projects",
|
||||||
|
"mimeType": "application/vnd.google-apps.folder",
|
||||||
|
"modifiedTime": "2024-06-10T12:00:00Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "synth_folder_d4e5f6",
|
||||||
|
"name": "Archive",
|
||||||
|
"mimeType": "application/vnd.google-apps.folder",
|
||||||
|
"modifiedTime": "2024-06-11T08:00:00Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "synth_file_g7h8i9",
|
||||||
|
"name": "report.pdf",
|
||||||
|
"mimeType": "application/pdf",
|
||||||
|
"size": "204800",
|
||||||
|
"modifiedTime": "2024-06-12T14:00:00Z",
|
||||||
|
"md5Checksum": "abc123synth"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
"root_page2": {
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"id": "synth_file_j0k1l2",
|
||||||
|
"name": "notes.txt",
|
||||||
|
"mimeType": "text/plain",
|
||||||
|
"size": "1024",
|
||||||
|
"modifiedTime": "2024-06-13T10:00:00Z",
|
||||||
|
"md5Checksum": "def456synth"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "synth_native_m3n4o5",
|
||||||
|
"name": "Meeting Notes",
|
||||||
|
"mimeType": "application/vnd.google-apps.document",
|
||||||
|
"modifiedTime": "2024-06-14T09:30:00Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
"nested_folder": {
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"id": "synth_nested_p6q7r8",
|
||||||
|
"name": "sub-document.docx",
|
||||||
|
"mimeType": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||||
|
"size": "51200",
|
||||||
|
"modifiedTime": "2024-06-15T11:00:00Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
"trashed_excluded": {
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"id": "synth_file_s9t0u1",
|
||||||
|
"name": "visible.pdf",
|
||||||
|
"mimeType": "application/pdf",
|
||||||
|
"size": "10240",
|
||||||
|
"modifiedTime": "2024-06-16T10:00:00Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"_note": "trashed=false query param ensures trashed items are not returned"
|
||||||
|
},
|
||||||
|
|
||||||
|
"native_docs_nullable_size": {
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"id": "synth_doc_v2w3x4",
|
||||||
|
"name": "Spreadsheet",
|
||||||
|
"mimeType": "application/vnd.google-apps.spreadsheet",
|
||||||
|
"modifiedTime": "2024-06-17T13:00:00Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "synth_slides_y5z6a7",
|
||||||
|
"name": "Presentation",
|
||||||
|
"mimeType": "application/vnd.google-apps.presentation",
|
||||||
|
"modifiedTime": "2024-06-18T14:00:00Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
+164
@@ -0,0 +1,164 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!--
|
||||||
|
Synthetic Nextcloud Depth-1 PROPFIND fixture — no real credentials, hostnames, tokens, or user data.
|
||||||
|
Root path: /remote.php/dav/files/testuser/
|
||||||
|
Represents: 4 folders + 6 files directly in root.
|
||||||
|
Covers: root self-entry filtering, absolute hrefs, encoded spaces/unicode, missing optional props.
|
||||||
|
-->
|
||||||
|
<d:multistatus xmlns:d="DAV:" xmlns:nc="http://nextcloud.org/ns" xmlns:oc="http://owncloud.org/ns">
|
||||||
|
|
||||||
|
<!-- Root self-entry — MUST be filtered out by the parser -->
|
||||||
|
<d:response>
|
||||||
|
<d:href>/remote.php/dav/files/testuser/</d:href>
|
||||||
|
<d:propstat>
|
||||||
|
<d:prop>
|
||||||
|
<d:resourcetype><d:collection/></d:resourcetype>
|
||||||
|
<d:displayname>testuser</d:displayname>
|
||||||
|
<d:getlastmodified>Mon, 01 Jan 2024 00:00:00 GMT</d:getlastmodified>
|
||||||
|
</d:prop>
|
||||||
|
<d:status>HTTP/1.1 200 OK</d:status>
|
||||||
|
</d:propstat>
|
||||||
|
</d:response>
|
||||||
|
|
||||||
|
<!-- Folder: Documents -->
|
||||||
|
<d:response>
|
||||||
|
<d:href>/remote.php/dav/files/testuser/Documents/</d:href>
|
||||||
|
<d:propstat>
|
||||||
|
<d:prop>
|
||||||
|
<d:resourcetype><d:collection/></d:resourcetype>
|
||||||
|
<d:displayname>Documents</d:displayname>
|
||||||
|
<d:getlastmodified>Mon, 10 Jun 2024 12:00:00 GMT</d:getlastmodified>
|
||||||
|
<oc:size>0</oc:size>
|
||||||
|
</d:prop>
|
||||||
|
<d:status>HTTP/1.1 200 OK</d:status>
|
||||||
|
</d:propstat>
|
||||||
|
</d:response>
|
||||||
|
|
||||||
|
<!-- Folder: Photos -->
|
||||||
|
<d:response>
|
||||||
|
<d:href>/remote.php/dav/files/testuser/Photos/</d:href>
|
||||||
|
<d:propstat>
|
||||||
|
<d:prop>
|
||||||
|
<d:resourcetype><d:collection/></d:resourcetype>
|
||||||
|
<d:displayname>Photos</d:displayname>
|
||||||
|
<d:getlastmodified>Tue, 11 Jun 2024 08:30:00 GMT</d:getlastmodified>
|
||||||
|
</d:prop>
|
||||||
|
<d:status>HTTP/1.1 200 OK</d:status>
|
||||||
|
</d:propstat>
|
||||||
|
</d:response>
|
||||||
|
|
||||||
|
<!-- Folder: My Work Files (contains spaces — encoded in href) -->
|
||||||
|
<d:response>
|
||||||
|
<d:href>/remote.php/dav/files/testuser/My%20Work%20Files/</d:href>
|
||||||
|
<d:propstat>
|
||||||
|
<d:prop>
|
||||||
|
<d:resourcetype><d:collection/></d:resourcetype>
|
||||||
|
<d:displayname>My Work Files</d:displayname>
|
||||||
|
<d:getlastmodified>Wed, 12 Jun 2024 09:00:00 GMT</d:getlastmodified>
|
||||||
|
</d:prop>
|
||||||
|
<d:status>HTTP/1.1 200 OK</d:status>
|
||||||
|
</d:propstat>
|
||||||
|
</d:response>
|
||||||
|
|
||||||
|
<!-- Folder: Unicode name: Café Designs -->
|
||||||
|
<d:response>
|
||||||
|
<d:href>/remote.php/dav/files/testuser/Caf%C3%A9%20Designs/</d:href>
|
||||||
|
<d:propstat>
|
||||||
|
<d:prop>
|
||||||
|
<d:resourcetype><d:collection/></d:resourcetype>
|
||||||
|
<d:displayname>Café Designs</d:displayname>
|
||||||
|
<d:getlastmodified>Thu, 13 Jun 2024 10:15:00 GMT</d:getlastmodified>
|
||||||
|
</d:prop>
|
||||||
|
<d:status>HTTP/1.1 200 OK</d:status>
|
||||||
|
</d:propstat>
|
||||||
|
</d:response>
|
||||||
|
|
||||||
|
<!-- File: report.pdf — all optional props present -->
|
||||||
|
<d:response>
|
||||||
|
<d:href>/remote.php/dav/files/testuser/report.pdf</d:href>
|
||||||
|
<d:propstat>
|
||||||
|
<d:prop>
|
||||||
|
<d:resourcetype/>
|
||||||
|
<d:displayname>report.pdf</d:displayname>
|
||||||
|
<d:getcontentlength>204800</d:getcontentlength>
|
||||||
|
<d:getcontenttype>application/pdf</d:getcontenttype>
|
||||||
|
<d:getlastmodified>Fri, 14 Jun 2024 14:00:00 GMT</d:getlastmodified>
|
||||||
|
<d:getetag>"abc123etag"</d:getetag>
|
||||||
|
</d:prop>
|
||||||
|
<d:status>HTTP/1.1 200 OK</d:status>
|
||||||
|
</d:propstat>
|
||||||
|
</d:response>
|
||||||
|
|
||||||
|
<!-- File: notes.txt — missing content-type and etag -->
|
||||||
|
<d:response>
|
||||||
|
<d:href>/remote.php/dav/files/testuser/notes.txt</d:href>
|
||||||
|
<d:propstat>
|
||||||
|
<d:prop>
|
||||||
|
<d:resourcetype/>
|
||||||
|
<d:displayname>notes.txt</d:displayname>
|
||||||
|
<d:getcontentlength>1024</d:getcontentlength>
|
||||||
|
<d:getlastmodified>Sat, 15 Jun 2024 10:00:00 GMT</d:getlastmodified>
|
||||||
|
</d:prop>
|
||||||
|
<d:status>HTTP/1.1 200 OK</d:status>
|
||||||
|
</d:propstat>
|
||||||
|
</d:response>
|
||||||
|
|
||||||
|
<!-- File: My Report Draft.docx — spaces in name -->
|
||||||
|
<d:response>
|
||||||
|
<d:href>/remote.php/dav/files/testuser/My%20Report%20Draft.docx</d:href>
|
||||||
|
<d:propstat>
|
||||||
|
<d:prop>
|
||||||
|
<d:resourcetype/>
|
||||||
|
<d:displayname>My Report Draft.docx</d:displayname>
|
||||||
|
<d:getcontentlength>51200</d:getcontentlength>
|
||||||
|
<d:getcontenttype>application/vnd.openxmlformats-officedocument.wordprocessingml.document</d:getcontenttype>
|
||||||
|
<d:getlastmodified>Sun, 16 Jun 2024 11:00:00 GMT</d:getlastmodified>
|
||||||
|
<d:getetag>"docx456etag"</d:getetag>
|
||||||
|
</d:prop>
|
||||||
|
<d:status>HTTP/1.1 200 OK</d:status>
|
||||||
|
</d:propstat>
|
||||||
|
</d:response>
|
||||||
|
|
||||||
|
<!-- File: image.png — size absent (should be None) -->
|
||||||
|
<d:response>
|
||||||
|
<d:href>/remote.php/dav/files/testuser/image.png</d:href>
|
||||||
|
<d:propstat>
|
||||||
|
<d:prop>
|
||||||
|
<d:resourcetype/>
|
||||||
|
<d:displayname>image.png</d:displayname>
|
||||||
|
<d:getcontenttype>image/png</d:getcontenttype>
|
||||||
|
<d:getlastmodified>Mon, 17 Jun 2024 12:30:00 GMT</d:getlastmodified>
|
||||||
|
</d:prop>
|
||||||
|
<d:status>HTTP/1.1 200 OK</d:status>
|
||||||
|
</d:propstat>
|
||||||
|
</d:response>
|
||||||
|
|
||||||
|
<!-- File: data.csv — all optional props absent (missing getcontenttype, getetag, size) -->
|
||||||
|
<d:response>
|
||||||
|
<d:href>/remote.php/dav/files/testuser/data.csv</d:href>
|
||||||
|
<d:propstat>
|
||||||
|
<d:prop>
|
||||||
|
<d:resourcetype/>
|
||||||
|
<d:displayname>data.csv</d:displayname>
|
||||||
|
</d:prop>
|
||||||
|
<d:status>HTTP/1.1 200 OK</d:status>
|
||||||
|
</d:propstat>
|
||||||
|
</d:response>
|
||||||
|
|
||||||
|
<!-- File: archive.zip — relative href (no leading slash) -->
|
||||||
|
<d:response>
|
||||||
|
<d:href>remote.php/dav/files/testuser/archive.zip</d:href>
|
||||||
|
<d:propstat>
|
||||||
|
<d:prop>
|
||||||
|
<d:resourcetype/>
|
||||||
|
<d:displayname>archive.zip</d:displayname>
|
||||||
|
<d:getcontentlength>102400</d:getcontentlength>
|
||||||
|
<d:getcontenttype>application/zip</d:getcontenttype>
|
||||||
|
<d:getlastmodified>Tue, 18 Jun 2024 09:00:00 GMT</d:getlastmodified>
|
||||||
|
<d:getetag>"zip789etag"</d:getetag>
|
||||||
|
</d:prop>
|
||||||
|
<d:status>HTTP/1.1 200 OK</d:status>
|
||||||
|
</d:propstat>
|
||||||
|
</d:response>
|
||||||
|
|
||||||
|
</d:multistatus>
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
{
|
||||||
|
"_comment": "Synthetic OneDrive fixture — no real credentials, tokens, drive IDs, or user data.",
|
||||||
|
|
||||||
|
"root_page1": {
|
||||||
|
"value": [
|
||||||
|
{
|
||||||
|
"id": "synth_od_folder_a1b2",
|
||||||
|
"name": "Projects",
|
||||||
|
"folder": {"childCount": 3},
|
||||||
|
"parentReference": {"driveId": "synth-drive-id", "id": "root"},
|
||||||
|
"lastModifiedDateTime": "2024-06-10T12:00:00Z",
|
||||||
|
"eTag": "etag_synth_folder_a1b2"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "synth_od_folder_c3d4",
|
||||||
|
"name": "My Archive",
|
||||||
|
"folder": {"childCount": 5},
|
||||||
|
"parentReference": {"driveId": "synth-drive-id", "id": "root"},
|
||||||
|
"lastModifiedDateTime": "2024-06-11T08:00:00Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "synth_od_file_e5f6",
|
||||||
|
"name": "report.pdf",
|
||||||
|
"file": {"mimeType": "application/pdf"},
|
||||||
|
"size": 204800,
|
||||||
|
"parentReference": {"driveId": "synth-drive-id", "id": "root"},
|
||||||
|
"lastModifiedDateTime": "2024-06-12T14:00:00Z",
|
||||||
|
"eTag": "etag_synth_file_e5f6"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"@odata.nextLink": "https://graph.microsoft.com/v1.0/me/drive/root/children?$skip=3&$top=3"
|
||||||
|
},
|
||||||
|
|
||||||
|
"root_page2": {
|
||||||
|
"value": [
|
||||||
|
{
|
||||||
|
"id": "synth_od_file_g7h8",
|
||||||
|
"name": "My Report Draft.docx",
|
||||||
|
"file": {"mimeType": "application/vnd.openxmlformats-officedocument.wordprocessingml.document"},
|
||||||
|
"size": 51200,
|
||||||
|
"parentReference": {"driveId": "synth-drive-id", "id": "root"},
|
||||||
|
"lastModifiedDateTime": "2024-06-13T10:00:00Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "synth_od_file_i9j0",
|
||||||
|
"name": "notes.txt",
|
||||||
|
"file": {"mimeType": "text/plain"},
|
||||||
|
"size": 1024,
|
||||||
|
"parentReference": {"driveId": "synth-drive-id", "id": "root"},
|
||||||
|
"lastModifiedDateTime": "2024-06-14T09:00:00Z",
|
||||||
|
"eTag": "etag_synth_file_i9j0"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
"nested_children": {
|
||||||
|
"value": [
|
||||||
|
{
|
||||||
|
"id": "synth_od_file_k1l2",
|
||||||
|
"name": "sub-document.docx",
|
||||||
|
"file": {"mimeType": "application/vnd.openxmlformats-officedocument.wordprocessingml.document"},
|
||||||
|
"size": 25600,
|
||||||
|
"parentReference": {"driveId": "synth-drive-id", "id": "synth_od_folder_a1b2"},
|
||||||
|
"lastModifiedDateTime": "2024-06-15T11:00:00Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
"nullable_metadata": {
|
||||||
|
"value": [
|
||||||
|
{
|
||||||
|
"id": "synth_od_file_m3n4",
|
||||||
|
"name": "unknown-file.bin",
|
||||||
|
"file": {},
|
||||||
|
"parentReference": {"driveId": "synth-drive-id", "id": "root"},
|
||||||
|
"lastModifiedDateTime": "2024-06-16T10:00:00Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
"encoded_names": {
|
||||||
|
"value": [
|
||||||
|
{
|
||||||
|
"id": "synth_od_file_o5p6",
|
||||||
|
"name": "Annual Report & Summary.pdf",
|
||||||
|
"file": {"mimeType": "application/pdf"},
|
||||||
|
"size": 102400,
|
||||||
|
"parentReference": {"driveId": "synth-drive-id", "id": "root"},
|
||||||
|
"lastModifiedDateTime": "2024-06-17T13:00:00Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
+105
@@ -0,0 +1,105 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!--
|
||||||
|
Synthetic generic WebDAV Depth-1 PROPFIND fixture — no real credentials, hostnames, tokens.
|
||||||
|
Root path: /dav/
|
||||||
|
Represents: 2 folders + 3 files directly in root.
|
||||||
|
Covers: absolute/relative hrefs, missing optional props, foreign href rejection.
|
||||||
|
-->
|
||||||
|
<d:multistatus xmlns:d="DAV:">
|
||||||
|
|
||||||
|
<!-- Root self-entry — MUST be filtered out -->
|
||||||
|
<d:response>
|
||||||
|
<d:href>/dav/</d:href>
|
||||||
|
<d:propstat>
|
||||||
|
<d:prop>
|
||||||
|
<d:resourcetype><d:collection/></d:resourcetype>
|
||||||
|
<d:displayname>root</d:displayname>
|
||||||
|
</d:prop>
|
||||||
|
<d:status>HTTP/1.1 200 OK</d:status>
|
||||||
|
</d:propstat>
|
||||||
|
</d:response>
|
||||||
|
|
||||||
|
<!-- Folder: Archive -->
|
||||||
|
<d:response>
|
||||||
|
<d:href>/dav/Archive/</d:href>
|
||||||
|
<d:propstat>
|
||||||
|
<d:prop>
|
||||||
|
<d:resourcetype><d:collection/></d:resourcetype>
|
||||||
|
<d:displayname>Archive</d:displayname>
|
||||||
|
<d:getlastmodified>Mon, 10 Jun 2024 08:00:00 GMT</d:getlastmodified>
|
||||||
|
</d:prop>
|
||||||
|
<d:status>HTTP/1.1 200 OK</d:status>
|
||||||
|
</d:propstat>
|
||||||
|
</d:response>
|
||||||
|
|
||||||
|
<!-- Folder: Shared (relative href) -->
|
||||||
|
<d:response>
|
||||||
|
<d:href>dav/Shared/</d:href>
|
||||||
|
<d:propstat>
|
||||||
|
<d:prop>
|
||||||
|
<d:resourcetype><d:collection/></d:resourcetype>
|
||||||
|
<d:displayname>Shared</d:displayname>
|
||||||
|
<d:getlastmodified>Tue, 11 Jun 2024 09:00:00 GMT</d:getlastmodified>
|
||||||
|
</d:prop>
|
||||||
|
<d:status>HTTP/1.1 200 OK</d:status>
|
||||||
|
</d:propstat>
|
||||||
|
</d:response>
|
||||||
|
|
||||||
|
<!-- File: readme.txt — all optional props -->
|
||||||
|
<d:response>
|
||||||
|
<d:href>/dav/readme.txt</d:href>
|
||||||
|
<d:propstat>
|
||||||
|
<d:prop>
|
||||||
|
<d:resourcetype/>
|
||||||
|
<d:displayname>readme.txt</d:displayname>
|
||||||
|
<d:getcontentlength>512</d:getcontentlength>
|
||||||
|
<d:getcontenttype>text/plain</d:getcontenttype>
|
||||||
|
<d:getlastmodified>Wed, 12 Jun 2024 10:00:00 GMT</d:getlastmodified>
|
||||||
|
<d:getetag>"readmeetag"</d:getetag>
|
||||||
|
</d:prop>
|
||||||
|
<d:status>HTTP/1.1 200 OK</d:status>
|
||||||
|
</d:propstat>
|
||||||
|
</d:response>
|
||||||
|
|
||||||
|
<!-- File: backup.tar.gz — missing optional props -->
|
||||||
|
<d:response>
|
||||||
|
<d:href>/dav/backup.tar.gz</d:href>
|
||||||
|
<d:propstat>
|
||||||
|
<d:prop>
|
||||||
|
<d:resourcetype/>
|
||||||
|
<d:displayname>backup.tar.gz</d:displayname>
|
||||||
|
<d:getcontentlength>10240</d:getcontentlength>
|
||||||
|
</d:prop>
|
||||||
|
<d:status>HTTP/1.1 200 OK</d:status>
|
||||||
|
</d:propstat>
|
||||||
|
</d:response>
|
||||||
|
|
||||||
|
<!-- File: project.pdf — encoded spaces in href -->
|
||||||
|
<d:response>
|
||||||
|
<d:href>/dav/project%20notes.pdf</d:href>
|
||||||
|
<d:propstat>
|
||||||
|
<d:prop>
|
||||||
|
<d:resourcetype/>
|
||||||
|
<d:displayname>project notes.pdf</d:displayname>
|
||||||
|
<d:getcontentlength>204800</d:getcontentlength>
|
||||||
|
<d:getcontenttype>application/pdf</d:getcontenttype>
|
||||||
|
<d:getlastmodified>Thu, 13 Jun 2024 11:00:00 GMT</d:getlastmodified>
|
||||||
|
</d:prop>
|
||||||
|
<d:status>HTTP/1.1 200 OK</d:status>
|
||||||
|
</d:propstat>
|
||||||
|
</d:response>
|
||||||
|
|
||||||
|
<!-- Foreign href — escapes configured root; MUST cause listing to be incomplete -->
|
||||||
|
<d:response>
|
||||||
|
<d:href>/other-root/escape.txt</d:href>
|
||||||
|
<d:propstat>
|
||||||
|
<d:prop>
|
||||||
|
<d:resourcetype/>
|
||||||
|
<d:displayname>escape.txt</d:displayname>
|
||||||
|
<d:getcontentlength>100</d:getcontentlength>
|
||||||
|
</d:prop>
|
||||||
|
<d:status>HTTP/1.1 200 OK</d:status>
|
||||||
|
</d:propstat>
|
||||||
|
</d:response>
|
||||||
|
|
||||||
|
</d:multistatus>
|
||||||
@@ -0,0 +1,825 @@
|
|||||||
|
"""
|
||||||
|
Phase 12.1 Plan 01 — Shared four-provider CloudResourceAdapter contract suite.
|
||||||
|
|
||||||
|
Purpose
|
||||||
|
-------
|
||||||
|
One parametrized contract harness asserting the canonical behavior of every
|
||||||
|
CloudResourceAdapter implementation across Nextcloud, generic WebDAV, Google
|
||||||
|
Drive, and OneDrive.
|
||||||
|
|
||||||
|
Contract assertions
|
||||||
|
-------------------
|
||||||
|
1. Adapter is a CloudResourceAdapter subclass.
|
||||||
|
2. list_folder matches the canonical positional/keyword signature:
|
||||||
|
(connection_id, user_id, parent_ref=None, page_token=None)
|
||||||
|
3. Invocation through the canonical signature does NOT raise TypeError.
|
||||||
|
4. Return type is CloudListing (never a list[dict] or any other shape).
|
||||||
|
5. Every resource carries the trusted caller connection_id and user_id.
|
||||||
|
6. provider_item_id is set (non-empty opaque reference).
|
||||||
|
7. kind is exactly "file" or "folder".
|
||||||
|
8. parent_ref in every resource matches the parent_ref argument.
|
||||||
|
9. Optional metadata (size, content_type, modified_at, etag) is None when
|
||||||
|
absent rather than a fabricated value.
|
||||||
|
10. Forbidden spies: get_object, put, put_object, delete_object, upload,
|
||||||
|
create_folder, rename, delete, move, copy methods are never called by
|
||||||
|
list_folder.
|
||||||
|
|
||||||
|
TDD note: test_provider_list_folder_contract MUST FAIL against the current
|
||||||
|
Nextcloud override (wrong signature → TypeError on canonical invocation).
|
||||||
|
All other providers should pass from the start.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import inspect
|
||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
# ── Fixture paths ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
FIXTURES_DIR = Path(__file__).parent / "fixtures" / "cloud"
|
||||||
|
NEXTCLOUD_ROOT_XML = (FIXTURES_DIR / "nextcloud_root.xml").read_text()
|
||||||
|
WEBDAV_ROOT_XML = (FIXTURES_DIR / "webdav_root.xml").read_text()
|
||||||
|
_GD_FIXTURE = json.loads((FIXTURES_DIR / "google_drive_pages.json").read_text())
|
||||||
|
_OD_FIXTURE = json.loads((FIXTURES_DIR / "onedrive_pages.json").read_text())
|
||||||
|
|
||||||
|
|
||||||
|
# ── Provider factory helpers ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _make_nextcloud_adapter():
|
||||||
|
"""Construct a NextcloudBackend bypassing SSRF validation."""
|
||||||
|
from storage.nextcloud_backend import NextcloudBackend
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
with patch("storage.webdav_backend.validate_cloud_url"):
|
||||||
|
with patch("webdav3.client.Client"):
|
||||||
|
backend = NextcloudBackend.__new__(NextcloudBackend)
|
||||||
|
backend._server_url = "https://nc.synth.example.com/remote.php/dav/files/testuser/"
|
||||||
|
backend._username = "testuser"
|
||||||
|
backend._client = MagicMock()
|
||||||
|
return backend
|
||||||
|
|
||||||
|
|
||||||
|
def _make_webdav_adapter():
|
||||||
|
"""Construct a WebDAVBackend bypassing SSRF validation."""
|
||||||
|
from storage.webdav_backend import WebDAVBackend
|
||||||
|
with patch("storage.webdav_backend.validate_cloud_url"):
|
||||||
|
with patch("webdav3.client.Client"):
|
||||||
|
backend = WebDAVBackend.__new__(WebDAVBackend)
|
||||||
|
backend._server_url = "https://dav.synth.example.com/dav/"
|
||||||
|
backend._client = MagicMock()
|
||||||
|
return backend
|
||||||
|
|
||||||
|
|
||||||
|
def _make_google_drive_adapter():
|
||||||
|
"""Construct a GoogleDriveBackend (no network credentials needed for tests)."""
|
||||||
|
from storage.google_drive_backend import GoogleDriveBackend
|
||||||
|
return GoogleDriveBackend({
|
||||||
|
"access_token": "synth_access_token",
|
||||||
|
"refresh_token": "synth_refresh_token",
|
||||||
|
"token_uri": "https://oauth2.googleapis.com/token",
|
||||||
|
"client_id": "synth_client_id",
|
||||||
|
"client_secret": "synth_client_secret",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def _make_onedrive_adapter():
|
||||||
|
"""Construct a OneDriveBackend (no network credentials needed for tests)."""
|
||||||
|
from storage.onedrive_backend import OneDriveBackend
|
||||||
|
return OneDriveBackend({
|
||||||
|
"access_token": "synth_access_token",
|
||||||
|
"refresh_token": "synth_refresh_token",
|
||||||
|
"expires_at": "2099-01-01T00:00:00",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
# ── Stub responses ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _webdav_list_stub(items: list[tuple[str, bool, int]]):
|
||||||
|
"""
|
||||||
|
items: list of (name, is_dir, size)
|
||||||
|
Returns a side_effect function for client.list() and client.info().
|
||||||
|
"""
|
||||||
|
def _list(path):
|
||||||
|
return [name for name, _, _ in items]
|
||||||
|
|
||||||
|
def _info(item_path):
|
||||||
|
for name, is_dir, size in items:
|
||||||
|
if name.rstrip("/") in item_path or item_path.endswith(name.rstrip("/")):
|
||||||
|
return {"isdir": is_dir, "size": size}
|
||||||
|
return {}
|
||||||
|
|
||||||
|
return _list, _info
|
||||||
|
|
||||||
|
|
||||||
|
# ── Provider case registry ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# Each case: (provider_name, factory_fn, stub_setup_fn)
|
||||||
|
# stub_setup_fn(adapter, connection_id, user_id) → patches set up for a successful root listing
|
||||||
|
|
||||||
|
def _stub_nextcloud(adapter, conn_id, user_id):
|
||||||
|
"""
|
||||||
|
Stub for Nextcloud canonical-signature list_folder.
|
||||||
|
Since the current Nextcloud override is incompatible, this stub
|
||||||
|
cannot help the existing implementation — the TypeError happens before
|
||||||
|
the stubs are ever reached. This is the expected RED behavior.
|
||||||
|
"""
|
||||||
|
# Minimal stubs for when the implementation is fixed:
|
||||||
|
adapter._client.list = MagicMock(return_value=["folder1/", "file.txt"])
|
||||||
|
adapter._client.info = MagicMock(return_value={"isdir": False, "size": 512})
|
||||||
|
|
||||||
|
|
||||||
|
def _stub_webdav(adapter, conn_id, user_id):
|
||||||
|
adapter._client.list = MagicMock(return_value=["subfolder/", "file.txt"])
|
||||||
|
adapter._client.info = MagicMock(side_effect=lambda p: {
|
||||||
|
"isdir": p.endswith("/"),
|
||||||
|
"size": 0 if p.endswith("/") else 512,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def _stub_google_drive(adapter, conn_id, user_id):
|
||||||
|
"""Stub Google Drive service to return one folder + one file."""
|
||||||
|
page1 = _GD_FIXTURE["root_page1"].copy()
|
||||||
|
del page1["nextPageToken"] # single page for basic contract test
|
||||||
|
page1["files"] = page1["files"][:2] # folder + file
|
||||||
|
fake_service = MagicMock()
|
||||||
|
fake_service.files().list().execute.return_value = page1
|
||||||
|
adapter._get_service = MagicMock(return_value=fake_service)
|
||||||
|
|
||||||
|
|
||||||
|
def _stub_onedrive(adapter, conn_id, user_id):
|
||||||
|
"""Stub OneDrive http client to return one folder + one file."""
|
||||||
|
page1 = {
|
||||||
|
"value": _OD_FIXTURE["root_page1"]["value"][:2], # folder + file
|
||||||
|
}
|
||||||
|
mock_resp = MagicMock()
|
||||||
|
mock_resp.is_success = True
|
||||||
|
mock_resp.json.return_value = page1
|
||||||
|
return mock_resp # caller uses this in httpx.AsyncClient patch
|
||||||
|
|
||||||
|
|
||||||
|
PROVIDER_CASES = [
|
||||||
|
pytest.param("nextcloud", _make_nextcloud_adapter, _stub_nextcloud, id="nextcloud"),
|
||||||
|
pytest.param("webdav", _make_webdav_adapter, _stub_webdav, id="webdav"),
|
||||||
|
pytest.param("google_drive", _make_google_drive_adapter, _stub_google_drive, id="google_drive"),
|
||||||
|
pytest.param("onedrive", _make_onedrive_adapter, _stub_onedrive, id="onedrive"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ── T-12.1-04: Forbidden-operation spies ─────────────────────────────────────
|
||||||
|
|
||||||
|
FORBIDDEN_METHODS = [
|
||||||
|
"get_object",
|
||||||
|
"put_object",
|
||||||
|
"delete_object",
|
||||||
|
"generate_presigned_put_url",
|
||||||
|
"presigned_get_url",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Provider-specific forbidden method names (class-level or attribute names)
|
||||||
|
_DRIVE_FORBIDDEN = ["get_media"]
|
||||||
|
_ONEDRIVE_FORBIDDEN = [] # /content checked via URL in separate test
|
||||||
|
|
||||||
|
|
||||||
|
# ── Static contract tests (no provider invocation needed) ─────────────────────
|
||||||
|
|
||||||
|
class TestAdapterIsCloudResourceAdapter:
|
||||||
|
"""Assert all four provider classes subclass CloudResourceAdapter."""
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("provider,factory_fn,_", PROVIDER_CASES)
|
||||||
|
def test_adapter_subclass(self, provider, factory_fn, _):
|
||||||
|
from storage.cloud_base import CloudResourceAdapter
|
||||||
|
adapter = factory_fn()
|
||||||
|
assert isinstance(adapter, CloudResourceAdapter), (
|
||||||
|
f"{provider} adapter does not implement CloudResourceAdapter"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestListFolderSignature:
|
||||||
|
"""Assert list_folder signature matches the canonical contract."""
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("provider,factory_fn,_", PROVIDER_CASES)
|
||||||
|
def test_canonical_signature(self, provider, factory_fn, _):
|
||||||
|
"""list_folder must accept (connection_id, user_id, parent_ref=None, page_token=None)."""
|
||||||
|
adapter = factory_fn()
|
||||||
|
sig = inspect.signature(adapter.list_folder)
|
||||||
|
params = list(sig.parameters.keys())
|
||||||
|
# self is not included in the signature of a bound method
|
||||||
|
assert "connection_id" in params, f"{provider}: missing connection_id param"
|
||||||
|
assert "user_id" in params, f"{provider}: missing user_id param"
|
||||||
|
assert "parent_ref" in params, f"{provider}: missing parent_ref param"
|
||||||
|
assert "page_token" in params, f"{provider}: missing page_token param"
|
||||||
|
# parent_ref and page_token must have defaults
|
||||||
|
assert sig.parameters["parent_ref"].default is None, (
|
||||||
|
f"{provider}: parent_ref default must be None"
|
||||||
|
)
|
||||||
|
assert sig.parameters["page_token"].default is None, (
|
||||||
|
f"{provider}: page_token default must be None"
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("provider,factory_fn,_", PROVIDER_CASES)
|
||||||
|
def test_list_folder_is_async(self, provider, factory_fn, _):
|
||||||
|
"""list_folder must be an async coroutine function."""
|
||||||
|
assert inspect.iscoroutinefunction(factory_fn().list_folder), (
|
||||||
|
f"{provider}: list_folder must be defined with 'async def'"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Dynamic contract tests (invoke list_folder through canonical signature) ───
|
||||||
|
|
||||||
|
class TestProviderListFolderContract:
|
||||||
|
"""
|
||||||
|
Core behavioral contract: canonical invocation, return type, resource identity.
|
||||||
|
|
||||||
|
test_provider_list_folder_contract MUST FAIL for nextcloud before Task 2
|
||||||
|
repairs the incompatible override (TypeError on positional connection_id argument).
|
||||||
|
"""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize("provider,factory_fn,stub_fn", PROVIDER_CASES)
|
||||||
|
async def test_provider_list_folder_contract(self, provider, factory_fn, stub_fn):
|
||||||
|
"""Canonical invocation (connection_id, user_id, parent_ref=None) returns CloudListing."""
|
||||||
|
from storage.cloud_base import CloudListing
|
||||||
|
adapter = factory_fn()
|
||||||
|
conn_id = uuid.uuid4()
|
||||||
|
user_id = uuid.uuid4()
|
||||||
|
|
||||||
|
if provider == "nextcloud":
|
||||||
|
with patch("storage.webdav_backend.validate_cloud_url"):
|
||||||
|
stub_fn(adapter, conn_id, user_id)
|
||||||
|
# This MUST raise TypeError with the current broken Nextcloud override.
|
||||||
|
# After Task 2 it must not raise and must return CloudListing.
|
||||||
|
result = await adapter.list_folder(conn_id, user_id, parent_ref=None)
|
||||||
|
elif provider == "webdav":
|
||||||
|
with patch("storage.webdav_backend.validate_cloud_url"):
|
||||||
|
stub_fn(adapter, conn_id, user_id)
|
||||||
|
result = await adapter.list_folder(conn_id, user_id, parent_ref=None)
|
||||||
|
elif provider == "google_drive":
|
||||||
|
stub_fn(adapter, conn_id, user_id)
|
||||||
|
result = await adapter.list_folder(conn_id, user_id, parent_ref=None)
|
||||||
|
elif provider == "onedrive":
|
||||||
|
mock_resp = stub_fn(adapter, conn_id, user_id)
|
||||||
|
with patch("httpx.AsyncClient") as mock_cls:
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||||
|
mock_client.__aexit__ = AsyncMock(return_value=None)
|
||||||
|
mock_client.get = AsyncMock(return_value=mock_resp)
|
||||||
|
mock_cls.return_value = mock_client
|
||||||
|
result = await adapter.list_folder(conn_id, user_id, parent_ref=None)
|
||||||
|
|
||||||
|
assert isinstance(result, CloudListing), (
|
||||||
|
f"{provider}: list_folder must return CloudListing, "
|
||||||
|
f"got {type(result).__name__!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize("provider,factory_fn,stub_fn", PROVIDER_CASES)
|
||||||
|
async def test_provider_root_and_nested_identity(self, provider, factory_fn, stub_fn):
|
||||||
|
"""Every resource carries trusted caller connection_id and user_id."""
|
||||||
|
from storage.cloud_base import CloudListing
|
||||||
|
adapter = factory_fn()
|
||||||
|
conn_id = uuid.uuid4()
|
||||||
|
user_id = uuid.uuid4()
|
||||||
|
|
||||||
|
if provider == "nextcloud":
|
||||||
|
with patch("storage.webdav_backend.validate_cloud_url"):
|
||||||
|
stub_fn(adapter, conn_id, user_id)
|
||||||
|
result = await adapter.list_folder(conn_id, user_id, parent_ref=None)
|
||||||
|
elif provider == "webdav":
|
||||||
|
with patch("storage.webdav_backend.validate_cloud_url"):
|
||||||
|
stub_fn(adapter, conn_id, user_id)
|
||||||
|
result = await adapter.list_folder(conn_id, user_id, parent_ref=None)
|
||||||
|
elif provider == "google_drive":
|
||||||
|
stub_fn(adapter, conn_id, user_id)
|
||||||
|
result = await adapter.list_folder(conn_id, user_id, parent_ref=None)
|
||||||
|
elif provider == "onedrive":
|
||||||
|
mock_resp = stub_fn(adapter, conn_id, user_id)
|
||||||
|
with patch("httpx.AsyncClient") as mock_cls:
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||||
|
mock_client.__aexit__ = AsyncMock(return_value=None)
|
||||||
|
mock_client.get = AsyncMock(return_value=mock_resp)
|
||||||
|
mock_cls.return_value = mock_client
|
||||||
|
result = await adapter.list_folder(conn_id, user_id, parent_ref=None)
|
||||||
|
|
||||||
|
assert isinstance(result, CloudListing)
|
||||||
|
for item in result.items:
|
||||||
|
assert item.connection_id == conn_id, (
|
||||||
|
f"{provider}: resource.connection_id must equal trusted caller; "
|
||||||
|
f"got {item.connection_id!r}"
|
||||||
|
)
|
||||||
|
assert item.user_id == user_id, (
|
||||||
|
f"{provider}: resource.user_id must equal trusted caller; "
|
||||||
|
f"got {item.user_id!r}"
|
||||||
|
)
|
||||||
|
assert item.provider_item_id, (
|
||||||
|
f"{provider}: provider_item_id must be non-empty"
|
||||||
|
)
|
||||||
|
assert item.kind in ("file", "folder"), (
|
||||||
|
f"{provider}: kind must be 'file' or 'folder', got {item.kind!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize("provider,factory_fn,stub_fn", PROVIDER_CASES)
|
||||||
|
async def test_provider_metadata_normalization(self, provider, factory_fn, stub_fn):
|
||||||
|
"""Absent optional metadata is None — not a fabricated default."""
|
||||||
|
from storage.cloud_base import CloudListing
|
||||||
|
adapter = factory_fn()
|
||||||
|
conn_id = uuid.uuid4()
|
||||||
|
user_id = uuid.uuid4()
|
||||||
|
|
||||||
|
if provider == "nextcloud":
|
||||||
|
# Use a stub that returns items with deliberately absent optional props
|
||||||
|
with patch("storage.webdav_backend.validate_cloud_url"):
|
||||||
|
adapter._client.list = MagicMock(return_value=["mystery.bin"])
|
||||||
|
adapter._client.info = MagicMock(return_value={})
|
||||||
|
result = await adapter.list_folder(conn_id, user_id, parent_ref=None)
|
||||||
|
elif provider == "webdav":
|
||||||
|
with patch("storage.webdav_backend.validate_cloud_url"):
|
||||||
|
adapter._client.list = MagicMock(return_value=["mystery.bin"])
|
||||||
|
adapter._client.info = MagicMock(return_value={})
|
||||||
|
result = await adapter.list_folder(conn_id, user_id, parent_ref=None)
|
||||||
|
elif provider == "google_drive":
|
||||||
|
fake_service = MagicMock()
|
||||||
|
fake_service.files().list().execute.return_value = {
|
||||||
|
"files": [{"id": "synth_xyz", "name": "noprop.txt", "mimeType": "text/plain"}]
|
||||||
|
}
|
||||||
|
adapter._get_service = MagicMock(return_value=fake_service)
|
||||||
|
result = await adapter.list_folder(conn_id, user_id, parent_ref=None)
|
||||||
|
elif provider == "onedrive":
|
||||||
|
page = {"value": [{"id": "synth_od_xyz", "name": "noprop.txt", "file": {}}]}
|
||||||
|
mock_resp = MagicMock()
|
||||||
|
mock_resp.is_success = True
|
||||||
|
mock_resp.json.return_value = page
|
||||||
|
with patch("httpx.AsyncClient") as mock_cls:
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||||
|
mock_client.__aexit__ = AsyncMock(return_value=None)
|
||||||
|
mock_client.get = AsyncMock(return_value=mock_resp)
|
||||||
|
mock_cls.return_value = mock_client
|
||||||
|
result = await adapter.list_folder(conn_id, user_id, parent_ref=None)
|
||||||
|
|
||||||
|
assert isinstance(result, CloudListing)
|
||||||
|
if result.items:
|
||||||
|
for item in result.items:
|
||||||
|
# Absent metadata must be None, not 0, "", or other fabricated values
|
||||||
|
# size for a file with no getcontentlength should be None or 0 (not negative)
|
||||||
|
assert item.size is None or isinstance(item.size, int), (
|
||||||
|
f"{provider}: size must be None or int, got {item.size!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize("provider,factory_fn,stub_fn", PROVIDER_CASES)
|
||||||
|
async def test_provider_consumes_all_pages_before_complete(self, provider, factory_fn, stub_fn):
|
||||||
|
"""complete=True only after all pages/responses are consumed."""
|
||||||
|
from storage.cloud_base import CloudListing
|
||||||
|
adapter = factory_fn()
|
||||||
|
conn_id = uuid.uuid4()
|
||||||
|
user_id = uuid.uuid4()
|
||||||
|
|
||||||
|
if provider == "nextcloud":
|
||||||
|
# Single-response DAV listing — always complete if no error
|
||||||
|
with patch("storage.webdav_backend.validate_cloud_url"):
|
||||||
|
adapter._client.list = MagicMock(return_value=["file.txt"])
|
||||||
|
adapter._client.info = MagicMock(return_value={"isdir": False, "size": 100})
|
||||||
|
result = await adapter.list_folder(conn_id, user_id, parent_ref=None)
|
||||||
|
assert result.complete is True
|
||||||
|
elif provider == "webdav":
|
||||||
|
with patch("storage.webdav_backend.validate_cloud_url"):
|
||||||
|
adapter._client.list = MagicMock(return_value=["file.txt"])
|
||||||
|
adapter._client.info = MagicMock(return_value={"isdir": False, "size": 100})
|
||||||
|
result = await adapter.list_folder(conn_id, user_id, parent_ref=None)
|
||||||
|
assert result.complete is True
|
||||||
|
elif provider == "google_drive":
|
||||||
|
# Two pages — complete=True only after page2 consumed
|
||||||
|
fake_service = MagicMock()
|
||||||
|
fake_service.files().list().execute.side_effect = [
|
||||||
|
{
|
||||||
|
"files": [{"id": "f1", "name": "a.pdf", "mimeType": "application/pdf", "size": "100"}],
|
||||||
|
"nextPageToken": "synth_page2",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"files": [{"id": "f2", "name": "b.pdf", "mimeType": "application/pdf", "size": "200"}],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
adapter._get_service = MagicMock(return_value=fake_service)
|
||||||
|
result = await adapter.list_folder(conn_id, user_id, parent_ref=None)
|
||||||
|
assert result.complete is True
|
||||||
|
assert len(result.items) == 2
|
||||||
|
elif provider == "onedrive":
|
||||||
|
page1 = {
|
||||||
|
"value": [{"id": "od1", "name": "a.txt", "file": {}, "size": 10}],
|
||||||
|
"@odata.nextLink": "https://graph.microsoft.com/v1.0/me/drive/root/children?$skip=1",
|
||||||
|
}
|
||||||
|
page2 = {
|
||||||
|
"value": [{"id": "od2", "name": "b.txt", "file": {}, "size": 20}],
|
||||||
|
}
|
||||||
|
resp1 = MagicMock()
|
||||||
|
resp1.is_success = True
|
||||||
|
resp1.json.return_value = page1
|
||||||
|
resp2 = MagicMock()
|
||||||
|
resp2.is_success = True
|
||||||
|
resp2.json.return_value = page2
|
||||||
|
with patch("httpx.AsyncClient") as mock_cls:
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||||
|
mock_client.__aexit__ = AsyncMock(return_value=None)
|
||||||
|
mock_client.get = AsyncMock(side_effect=[resp1, resp2])
|
||||||
|
mock_cls.return_value = mock_client
|
||||||
|
result = await adapter.list_folder(conn_id, user_id, parent_ref=None)
|
||||||
|
assert result.complete is True
|
||||||
|
assert len(result.items) == 2
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize("provider,factory_fn,stub_fn", PROVIDER_CASES)
|
||||||
|
async def test_provider_page_failure_is_incomplete(self, provider, factory_fn, stub_fn):
|
||||||
|
"""A failed fetch/parse returns complete=False — never a fabricated complete empty."""
|
||||||
|
from storage.cloud_base import CloudListing
|
||||||
|
adapter = factory_fn()
|
||||||
|
conn_id = uuid.uuid4()
|
||||||
|
user_id = uuid.uuid4()
|
||||||
|
|
||||||
|
if provider in ("nextcloud", "webdav"):
|
||||||
|
with patch("storage.webdav_backend.validate_cloud_url"):
|
||||||
|
adapter._client.list = MagicMock(side_effect=Exception("network failure"))
|
||||||
|
result = await adapter.list_folder(conn_id, user_id, parent_ref=None)
|
||||||
|
elif provider == "google_drive":
|
||||||
|
fake_service = MagicMock()
|
||||||
|
from googleapiclient.errors import HttpError
|
||||||
|
resp = MagicMock()
|
||||||
|
resp.status = 403
|
||||||
|
fake_service.files().list().execute.side_effect = HttpError(resp, b"forbidden")
|
||||||
|
adapter._get_service = MagicMock(return_value=fake_service)
|
||||||
|
result = await adapter.list_folder(conn_id, user_id, parent_ref=None)
|
||||||
|
elif provider == "onedrive":
|
||||||
|
err_resp = MagicMock()
|
||||||
|
err_resp.is_success = False
|
||||||
|
err_resp.status_code = 401
|
||||||
|
with patch("httpx.AsyncClient") as mock_cls:
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||||
|
mock_client.__aexit__ = AsyncMock(return_value=None)
|
||||||
|
mock_client.get = AsyncMock(return_value=err_resp)
|
||||||
|
mock_cls.return_value = mock_client
|
||||||
|
result = await adapter.list_folder(conn_id, user_id, parent_ref=None)
|
||||||
|
|
||||||
|
assert isinstance(result, CloudListing)
|
||||||
|
assert result.complete is False, (
|
||||||
|
f"{provider}: fetch failure must return complete=False"
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize("provider,factory_fn,stub_fn", PROVIDER_CASES)
|
||||||
|
async def test_provider_listing_never_downloads_or_mutates(
|
||||||
|
self, provider, factory_fn, stub_fn
|
||||||
|
):
|
||||||
|
"""list_folder must not call get_object, put_object, delete_object,
|
||||||
|
presigned_get_url, or generate_presigned_put_url."""
|
||||||
|
from storage.cloud_base import CloudListing
|
||||||
|
adapter = factory_fn()
|
||||||
|
conn_id = uuid.uuid4()
|
||||||
|
user_id = uuid.uuid4()
|
||||||
|
|
||||||
|
# Install failing spies on all forbidden methods
|
||||||
|
forbidden_patches = []
|
||||||
|
for method in FORBIDDEN_METHODS:
|
||||||
|
if hasattr(adapter, method):
|
||||||
|
p = patch.object(
|
||||||
|
adapter, method,
|
||||||
|
side_effect=AssertionError(f"FORBIDDEN: {method} was called by list_folder"),
|
||||||
|
)
|
||||||
|
forbidden_patches.append(p)
|
||||||
|
|
||||||
|
for p in forbidden_patches:
|
||||||
|
p.start()
|
||||||
|
|
||||||
|
try:
|
||||||
|
if provider in ("nextcloud", "webdav"):
|
||||||
|
with patch("storage.webdav_backend.validate_cloud_url"):
|
||||||
|
adapter._client.list = MagicMock(return_value=["file.txt"])
|
||||||
|
adapter._client.info = MagicMock(return_value={"isdir": False, "size": 100})
|
||||||
|
result = await adapter.list_folder(conn_id, user_id, parent_ref=None)
|
||||||
|
elif provider == "google_drive":
|
||||||
|
fake_service = MagicMock()
|
||||||
|
fake_service.files().list().execute.return_value = {
|
||||||
|
"files": [{"id": "synth_f1", "name": "x.pdf", "mimeType": "application/pdf", "size": "100"}]
|
||||||
|
}
|
||||||
|
# Also spy on get_media method of Drive service
|
||||||
|
fake_service.files().get_media.side_effect = AssertionError(
|
||||||
|
"FORBIDDEN: get_media called"
|
||||||
|
)
|
||||||
|
adapter._get_service = MagicMock(return_value=fake_service)
|
||||||
|
result = await adapter.list_folder(conn_id, user_id, parent_ref=None)
|
||||||
|
elif provider == "onedrive":
|
||||||
|
page = {"value": [{"id": "synth_od1", "name": "y.txt", "file": {}, "size": 10}]}
|
||||||
|
mock_resp = MagicMock()
|
||||||
|
mock_resp.is_success = True
|
||||||
|
mock_resp.json.return_value = page
|
||||||
|
with patch("httpx.AsyncClient") as mock_cls:
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||||
|
mock_client.__aexit__ = AsyncMock(return_value=None)
|
||||||
|
mock_client.get = AsyncMock(return_value=mock_resp)
|
||||||
|
# Spy on PUT, POST, DELETE
|
||||||
|
mock_client.put = AsyncMock(side_effect=AssertionError("FORBIDDEN: put called"))
|
||||||
|
mock_client.post = AsyncMock(side_effect=AssertionError("FORBIDDEN: post called"))
|
||||||
|
mock_client.delete = AsyncMock(side_effect=AssertionError("FORBIDDEN: delete called"))
|
||||||
|
mock_cls.return_value = mock_client
|
||||||
|
result = await adapter.list_folder(conn_id, user_id, parent_ref=None)
|
||||||
|
|
||||||
|
assert isinstance(result, CloudListing), (
|
||||||
|
f"{provider}: result must be CloudListing after forbidden-method spy pass"
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
for p in forbidden_patches:
|
||||||
|
p.stop()
|
||||||
|
|
||||||
|
|
||||||
|
class TestProviderParentRefPropagation:
|
||||||
|
"""parent_ref is correctly propagated into each CloudResource."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize("provider,factory_fn,stub_fn", [
|
||||||
|
pytest.param("webdav", _make_webdav_adapter, _stub_webdav, id="webdav"),
|
||||||
|
pytest.param("google_drive", _make_google_drive_adapter, _stub_google_drive, id="google_drive"),
|
||||||
|
pytest.param("onedrive", _make_onedrive_adapter, _stub_onedrive, id="onedrive"),
|
||||||
|
])
|
||||||
|
async def test_parent_ref_copied_to_resources(self, provider, factory_fn, stub_fn):
|
||||||
|
"""Every resource.parent_ref equals the parent_ref argument."""
|
||||||
|
adapter = factory_fn()
|
||||||
|
conn_id = uuid.uuid4()
|
||||||
|
user_id = uuid.uuid4()
|
||||||
|
parent = "synth_parent_folder_ref"
|
||||||
|
|
||||||
|
if provider == "webdav":
|
||||||
|
with patch("storage.webdav_backend.validate_cloud_url"):
|
||||||
|
adapter._client.list = MagicMock(return_value=["file.txt"])
|
||||||
|
adapter._client.info = MagicMock(return_value={"isdir": False, "size": 100})
|
||||||
|
result = await adapter.list_folder(conn_id, user_id, parent_ref=parent)
|
||||||
|
elif provider == "google_drive":
|
||||||
|
fake_service = MagicMock()
|
||||||
|
fake_service.files().list().execute.return_value = {
|
||||||
|
"files": [{"id": "synth_f2", "name": "z.pdf", "mimeType": "application/pdf", "size": "100"}]
|
||||||
|
}
|
||||||
|
adapter._get_service = MagicMock(return_value=fake_service)
|
||||||
|
result = await adapter.list_folder(conn_id, user_id, parent_ref=parent)
|
||||||
|
elif provider == "onedrive":
|
||||||
|
page = {"value": [{"id": "synth_od2", "name": "w.txt", "file": {}, "size": 20}]}
|
||||||
|
mock_resp = MagicMock()
|
||||||
|
mock_resp.is_success = True
|
||||||
|
mock_resp.json.return_value = page
|
||||||
|
with patch("httpx.AsyncClient") as mock_cls:
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||||
|
mock_client.__aexit__ = AsyncMock(return_value=None)
|
||||||
|
mock_client.get = AsyncMock(return_value=mock_resp)
|
||||||
|
mock_cls.return_value = mock_client
|
||||||
|
result = await adapter.list_folder(conn_id, user_id, parent_ref=parent)
|
||||||
|
|
||||||
|
for item in result.items:
|
||||||
|
assert item.parent_ref == parent, (
|
||||||
|
f"{provider}: resource.parent_ref={item.parent_ref!r} "
|
||||||
|
f"does not match argument parent_ref={parent!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestProviderHostileIdentityRejection:
|
||||||
|
"""Provider response fields must not override trusted caller identity."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_google_drive_hostile_ids_overridden(self):
|
||||||
|
"""owner_id / connection_id in Drive response cannot replace trusted caller values."""
|
||||||
|
from storage.google_drive_backend import GoogleDriveBackend
|
||||||
|
adapter = _make_google_drive_adapter()
|
||||||
|
conn_id = uuid.uuid4()
|
||||||
|
user_id = uuid.uuid4()
|
||||||
|
hostile_id = uuid.uuid4()
|
||||||
|
|
||||||
|
# The Drive response does not carry connection_id or user_id, but even if it
|
||||||
|
# returned parent references that look like UUIDs, those must not replace them.
|
||||||
|
fake_service = MagicMock()
|
||||||
|
fake_service.files().list().execute.return_value = {
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"id": str(hostile_id), # hostile provider_item_id != our user_id
|
||||||
|
"name": "file.pdf",
|
||||||
|
"mimeType": "application/pdf",
|
||||||
|
"size": "100",
|
||||||
|
"parents": [str(hostile_id)], # parent also hostile
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
adapter._get_service = MagicMock(return_value=fake_service)
|
||||||
|
result = await adapter.list_folder(conn_id, user_id, parent_ref=None)
|
||||||
|
|
||||||
|
for item in result.items:
|
||||||
|
assert item.connection_id == conn_id
|
||||||
|
assert item.user_id == user_id
|
||||||
|
# provider_item_id may equal hostile_id (it is opaque) — that is fine
|
||||||
|
# but connection_id and user_id must be the trusted caller values
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_onedrive_hostile_ids_overridden(self):
|
||||||
|
"""owner from @odata response cannot replace trusted caller identity."""
|
||||||
|
adapter = _make_onedrive_adapter()
|
||||||
|
conn_id = uuid.uuid4()
|
||||||
|
user_id = uuid.uuid4()
|
||||||
|
hostile_user = uuid.uuid4()
|
||||||
|
|
||||||
|
page = {
|
||||||
|
"value": [
|
||||||
|
{
|
||||||
|
"id": "od_item1",
|
||||||
|
"name": "x.txt",
|
||||||
|
"file": {},
|
||||||
|
"size": 100,
|
||||||
|
"createdBy": {"user": {"id": str(hostile_user)}},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
mock_resp = MagicMock()
|
||||||
|
mock_resp.is_success = True
|
||||||
|
mock_resp.json.return_value = page
|
||||||
|
with patch("httpx.AsyncClient") as mock_cls:
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||||
|
mock_client.__aexit__ = AsyncMock(return_value=None)
|
||||||
|
mock_client.get = AsyncMock(return_value=mock_resp)
|
||||||
|
mock_cls.return_value = mock_client
|
||||||
|
result = await adapter.list_folder(conn_id, user_id, parent_ref=None)
|
||||||
|
|
||||||
|
for item in result.items:
|
||||||
|
assert item.connection_id == conn_id
|
||||||
|
assert item.user_id == user_id
|
||||||
|
|
||||||
|
|
||||||
|
class TestNextcloudSpecificContract:
|
||||||
|
"""Nextcloud-specific regression tests (P0 from research)."""
|
||||||
|
|
||||||
|
def test_nextcloud_signature_matches_canonical_webdav(self):
|
||||||
|
"""
|
||||||
|
inspect.signature(NextcloudBackend.list_folder) must match WebDAVBackend.list_folder.
|
||||||
|
|
||||||
|
This test is the root-cause regression for the P0 defect documented in research:
|
||||||
|
NextcloudBackend.list_folder(folder_path="") breaks the canonical four-argument call.
|
||||||
|
Before Task 2 this test will fail; after Task 2 it must pass.
|
||||||
|
"""
|
||||||
|
from storage.nextcloud_backend import NextcloudBackend
|
||||||
|
from storage.webdav_backend import WebDAVBackend
|
||||||
|
|
||||||
|
nc_sig = inspect.signature(NextcloudBackend.list_folder)
|
||||||
|
wd_sig = inspect.signature(WebDAVBackend.list_folder)
|
||||||
|
|
||||||
|
nc_params = list(nc_sig.parameters.keys())
|
||||||
|
wd_params = list(wd_sig.parameters.keys())
|
||||||
|
|
||||||
|
assert nc_params == wd_params, (
|
||||||
|
f"NextcloudBackend.list_folder signature {nc_params!r} "
|
||||||
|
f"does not match WebDAVBackend.list_folder signature {wd_params!r}. "
|
||||||
|
f"Remove the incompatible override so Nextcloud inherits the canonical method."
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_nextcloud_canonical_invocation_does_not_raise_type_error(self):
|
||||||
|
"""
|
||||||
|
Calling adapter.list_folder(connection_id, user_id) must not raise TypeError.
|
||||||
|
|
||||||
|
This is the canonical test for the P0 production defect. The existing override
|
||||||
|
raises TypeError because the browse endpoint passes positional UUID arguments
|
||||||
|
but the override expects (folder_path: str = "").
|
||||||
|
"""
|
||||||
|
adapter = _make_nextcloud_adapter()
|
||||||
|
conn_id = uuid.uuid4()
|
||||||
|
user_id = uuid.uuid4()
|
||||||
|
|
||||||
|
with patch("storage.webdav_backend.validate_cloud_url"):
|
||||||
|
adapter._client.list = MagicMock(return_value=["file.txt"])
|
||||||
|
adapter._client.info = MagicMock(return_value={"isdir": False, "size": 100})
|
||||||
|
# Must not raise TypeError — after Task 2 this invokes the inherited WebDAV method
|
||||||
|
result = await adapter.list_folder(conn_id, user_id)
|
||||||
|
|
||||||
|
from storage.cloud_base import CloudListing
|
||||||
|
assert isinstance(result, CloudListing)
|
||||||
|
|
||||||
|
def test_nextcloud_inherits_or_delegates_to_webdav_list_folder(self):
|
||||||
|
"""
|
||||||
|
NextcloudBackend.list_folder must be the same function as WebDAVBackend.list_folder
|
||||||
|
OR a method that wraps/delegates to it without changing the public contract.
|
||||||
|
"""
|
||||||
|
from storage.nextcloud_backend import NextcloudBackend
|
||||||
|
from storage.webdav_backend import WebDAVBackend
|
||||||
|
|
||||||
|
# After Task 2: either NextcloudBackend does not define its own list_folder
|
||||||
|
# (direct inheritance), or it defines one with the matching signature.
|
||||||
|
nc_fn = NextcloudBackend.__dict__.get("list_folder")
|
||||||
|
if nc_fn is not None:
|
||||||
|
# If Nextcloud overrides list_folder, its signature must still be canonical
|
||||||
|
sig = inspect.signature(nc_fn)
|
||||||
|
params = list(sig.parameters.keys())
|
||||||
|
# skip 'self'
|
||||||
|
expected = ["self", "connection_id", "user_id", "parent_ref", "page_token"]
|
||||||
|
assert params == expected, (
|
||||||
|
f"NextcloudBackend defines its own list_folder but signature {params!r} "
|
||||||
|
f"is not canonical {expected!r}. Either remove the override or fix the signature."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestGoogleDriveSpecificContract:
|
||||||
|
"""Drive-specific normalization tests."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_native_google_files_have_nullable_size(self):
|
||||||
|
"""Native Google Workspace files (Docs, Sheets, etc.) must normalize with size=None."""
|
||||||
|
adapter = _make_google_drive_adapter()
|
||||||
|
conn_id = uuid.uuid4()
|
||||||
|
user_id = uuid.uuid4()
|
||||||
|
|
||||||
|
fixture_data = _GD_FIXTURE["native_docs_nullable_size"]
|
||||||
|
fake_service = MagicMock()
|
||||||
|
fake_service.files().list().execute.return_value = fixture_data
|
||||||
|
adapter._get_service = MagicMock(return_value=fake_service)
|
||||||
|
|
||||||
|
result = await adapter.list_folder(conn_id, user_id, parent_ref=None)
|
||||||
|
assert result.complete is True
|
||||||
|
for item in result.items:
|
||||||
|
assert item.kind == "file", f"Native Google type should be kind=file, got {item.kind!r}"
|
||||||
|
assert item.size is None, f"Native Google file size must be None, got {item.size!r}"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_trashed_items_excluded(self):
|
||||||
|
"""trashed=false in the query means trashed items never appear in results."""
|
||||||
|
adapter = _make_google_drive_adapter()
|
||||||
|
conn_id = uuid.uuid4()
|
||||||
|
user_id = uuid.uuid4()
|
||||||
|
|
||||||
|
# The fixture confirms query uses trashed=false — the result set only has visible items
|
||||||
|
fixture_data = _GD_FIXTURE["trashed_excluded"]
|
||||||
|
fake_service = MagicMock()
|
||||||
|
fake_service.files().list().execute.return_value = fixture_data
|
||||||
|
adapter._get_service = MagicMock(return_value=fake_service)
|
||||||
|
|
||||||
|
# Verify the query string passed to files().list() contains trashed=false
|
||||||
|
result = await adapter.list_folder(conn_id, user_id, parent_ref=None)
|
||||||
|
call_kwargs = fake_service.files().list.call_args
|
||||||
|
if call_kwargs:
|
||||||
|
q_param = call_kwargs[1].get("q", "") or (call_kwargs[0][0] if call_kwargs[0] else "")
|
||||||
|
assert "trashed=false" in q_param, (
|
||||||
|
"Drive query must contain 'trashed=false' to exclude trash items"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestOneDriveSpecificContract:
|
||||||
|
"""OneDrive-specific normalization tests."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_folder_identified_by_facet(self):
|
||||||
|
"""OneDrive items with 'folder' key are kind='folder'."""
|
||||||
|
adapter = _make_onedrive_adapter()
|
||||||
|
conn_id = uuid.uuid4()
|
||||||
|
user_id = uuid.uuid4()
|
||||||
|
|
||||||
|
page = _OD_FIXTURE["root_page1"].copy()
|
||||||
|
del page["@odata.nextLink"]
|
||||||
|
page["value"] = page["value"][:2] # first is folder, second is also folder
|
||||||
|
|
||||||
|
mock_resp = MagicMock()
|
||||||
|
mock_resp.is_success = True
|
||||||
|
mock_resp.json.return_value = page
|
||||||
|
with patch("httpx.AsyncClient") as mock_cls:
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||||
|
mock_client.__aexit__ = AsyncMock(return_value=None)
|
||||||
|
mock_client.get = AsyncMock(return_value=mock_resp)
|
||||||
|
mock_cls.return_value = mock_client
|
||||||
|
result = await adapter.list_folder(conn_id, user_id, parent_ref=None)
|
||||||
|
|
||||||
|
folders = [i for i in result.items if i.kind == "folder"]
|
||||||
|
assert len(folders) >= 1, "Expected at least one folder from OneDrive fixture"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_nullable_metadata_items(self):
|
||||||
|
"""OneDrive items with absent file.mimeType or size normalize to None."""
|
||||||
|
adapter = _make_onedrive_adapter()
|
||||||
|
conn_id = uuid.uuid4()
|
||||||
|
user_id = uuid.uuid4()
|
||||||
|
|
||||||
|
fixture_data = _OD_FIXTURE["nullable_metadata"]
|
||||||
|
mock_resp = MagicMock()
|
||||||
|
mock_resp.is_success = True
|
||||||
|
mock_resp.json.return_value = fixture_data
|
||||||
|
with patch("httpx.AsyncClient") as mock_cls:
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||||
|
mock_client.__aexit__ = AsyncMock(return_value=None)
|
||||||
|
mock_client.get = AsyncMock(return_value=mock_resp)
|
||||||
|
mock_cls.return_value = mock_client
|
||||||
|
result = await adapter.list_folder(conn_id, user_id, parent_ref=None)
|
||||||
|
|
||||||
|
assert len(result.items) == 1
|
||||||
|
item = result.items[0]
|
||||||
|
assert item.kind == "file"
|
||||||
|
# content_type may be None when 'file' key has no mimeType
|
||||||
|
assert item.content_type is None or isinstance(item.content_type, str)
|
||||||
Reference in New Issue
Block a user