|
8 | 8 | """ |
9 | 9 |
|
10 | 10 | import hashlib |
| 11 | +from urllib.parse import urlparse |
11 | 12 |
|
12 | 13 | import requests |
13 | 14 |
|
@@ -55,6 +56,17 @@ def test_presign_upload_file_reachable_after_direct_put(session, artifacts_url): |
55 | 56 | assert dl.content == data |
56 | 57 |
|
57 | 58 |
|
| 59 | +def test_presign_upload_encodes_colons_in_url(session, artifacts_url): |
| 60 | + """Presigned URL path must use %3A for ':' — GCS normalises ':' to '%3A' |
| 61 | + when computing StringToSign, so a literal ':' causes SignatureDoesNotMatch.""" |
| 62 | + resp = session.get(f'{artifacts_url}/presign-upload/{STAGING_BUILD}/colon-check.txt') |
| 63 | + assert resp.status_code == 200 |
| 64 | + url = resp.text.strip() |
| 65 | + path = urlparse(url).path |
| 66 | + assert '%3A' in path, f"Expected '%3A' in presigned URL path, got: {path!r}" |
| 67 | + assert ':' not in path, f"Raw ':' in presigned URL path causes SignatureDoesNotMatch on GCS" |
| 68 | + |
| 69 | + |
58 | 70 | def test_presign_upload_rejects_non_get(session, artifacts_url): |
59 | 71 | """Only GET is allowed on /presign-upload/; other methods return 400.""" |
60 | 72 | url = f'{artifacts_url}/presign-upload/{STAGING_BUILD}/file.txt' |
@@ -94,24 +106,39 @@ def test_presign_upload_part_returns_url(session, artifacts_url): |
94 | 106 |
|
95 | 107 | def test_presign_upload_part_encodes_special_chars_in_upload_id(session, artifacts_url): |
96 | 108 | """uploadId containing +, / and = (GCS-style base64) is percent-encoded in the presigned URL.""" |
97 | | - # Use a synthetic uploadId with base64 special characters. The PRESIGN_PART |
98 | | - # endpoint does not validate that the uploadId corresponds to a real upload, |
99 | | - # so we can inject one directly to verify the encoding behaviour without |
100 | | - # needing a backend that generates such IDs. |
101 | 109 | special_upload_id = 'abc+def/ghi=jkl' |
102 | 110 | resp = session.get( |
103 | 111 | f'{artifacts_url}/presign-upload-part/{STAGING_BUILD}/presign/encoding-test.bin', |
104 | 112 | params={'partNumber': 1, 'uploadId': special_upload_id}, |
105 | 113 | ) |
106 | 114 | assert resp.status_code == 200, f'{resp.status_code} {resp.text}' |
107 | 115 | url = resp.text.strip() |
108 | | - # The uploadId must appear percent-encoded in the presigned URL so that GCS |
109 | | - # can reconstruct the canonical resource from the URL literally (GCS V2 spec). |
110 | 116 | assert 'uploadId=abc%2Bdef%2Fghi%3Djkl' in url, ( |
111 | 117 | f'Expected uploadId to be percent-encoded in presigned URL, got: {url!r}' |
112 | 118 | ) |
113 | 119 |
|
114 | 120 |
|
| 121 | +def test_presign_upload_part_encodes_colons_in_url(session, artifacts_url): |
| 122 | + """Presigned part URL path must use %3A for ':' — GCS normalises ':' to '%3A' |
| 123 | + when computing StringToSign, so a literal ':' causes SignatureDoesNotMatch.""" |
| 124 | + upload_id = multipart_initiate(session, artifacts_url, STAGING_BUILD, 'presign/colon-part.bin') |
| 125 | + try: |
| 126 | + resp = session.get( |
| 127 | + f'{artifacts_url}/presign-upload-part/{STAGING_BUILD}/presign/colon-part.bin', |
| 128 | + params={'partNumber': 1, 'uploadId': upload_id}, |
| 129 | + ) |
| 130 | + assert resp.status_code == 200, f'{resp.status_code} {resp.text}' |
| 131 | + url = resp.text.strip() |
| 132 | + path = urlparse(url).path |
| 133 | + assert '%3A' in path, f"Expected '%3A' in presigned part URL path, got: {path!r}" |
| 134 | + assert ':' not in path, f"Raw ':' in presigned part URL path causes SignatureDoesNotMatch on GCS" |
| 135 | + finally: |
| 136 | + session.delete( |
| 137 | + f'{artifacts_url}/upload-multipart/abort/{STAGING_BUILD}/presign/colon-part.bin', |
| 138 | + params={'uploadId': upload_id}, |
| 139 | + ) |
| 140 | + |
| 141 | + |
115 | 142 | def test_presign_multipart_full_round_trip(session, artifacts_url): |
116 | 143 | """Initiate via nginx, upload parts directly to S3, complete via nginx.""" |
117 | 144 | build = STAGING_BUILD |
|
0 commit comments