Files

758 lines
21 KiB
TypeScript

/**
* @license
* Copyright 2017 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { assert } from 'chai';
import { FbsBlob } from '../../src/implementation/blob';
import { Location } from '../../src/implementation/location';
import {
getMappings,
fromResourceString
} from '../../src/implementation/metadata';
import {
getMetadata,
list,
getDownloadUrl,
updateMetadata,
deleteObject,
multipartUpload,
createResumableUpload,
getResumableUploadStatus,
ResumableUploadStatus,
continueResumableUpload,
RESUMABLE_UPLOAD_CHUNK_SIZE,
getBytes
} from '../../src/implementation/requests';
import { makeUrl } from '../../src/implementation/url';
import { unknown, StorageErrorCode } from '../../src/implementation/error';
import { RequestInfo } from '../../src/implementation/requestinfo';
import { Metadata } from '../../src/metadata';
import { FirebaseStorageImpl } from '../../src/service';
import {
assertObjectIncludes,
fakeXhrIo,
fakeAuthProvider,
fakeAppCheckTokenProvider
} from './testshared';
import {
DEFAULT_HOST,
CONFIG_STORAGE_BUCKET_KEY
} from '../../src/implementation/constants';
import { FirebaseApp } from '@firebase/app-types';
import { decodeUint8Array } from '../../src/platform/base64';
describe('Firebase Storage > Requests', () => {
const normalBucket = 'b';
const differentBucket = 'c';
const locationRoot = new Location(normalBucket, '');
const locationNormal = new Location(normalBucket, 'o');
const locationNormalUrl = '/b/' + normalBucket + '/o/o';
const locationDifferentBucket = new Location(differentBucket, '');
const locationNormalNoObjUrl = '/b/' + normalBucket + '/o';
const locationEscapes = new Location('b/', 'o?');
const locationEscapesUrl = '/b/b%2F/o/o%3F';
const locationEscapesNoObjUrl = '/b/b%2F/o';
const smallBlob = new FbsBlob(new Uint8Array([97]));
const smallBlobString = 'a';
const bigBlob = new FbsBlob(new ArrayBuffer(1024 * 1024));
const mappings = getMappings();
const mockApp: FirebaseApp = {
name: 'mock-app',
options: {
[CONFIG_STORAGE_BUCKET_KEY]: 'fredzqm-staging'
},
automaticDataCollectionEnabled: false,
delete: async () => undefined
};
const storageService = new FirebaseStorageImpl(
mockApp,
fakeAuthProvider,
fakeAppCheckTokenProvider
);
const contentTypeInMetadata = 'application/jason';
const metadata = {
contentType: contentTypeInMetadata,
customMetadata: {
// no-inline
foo: 'bar'
}
} as any as Metadata;
const metadataString = JSON.stringify({
// no-inline
contentType: contentTypeInMetadata,
metadata: {
// no-inline
foo: 'bar'
}
});
const serverResource = {
bucket: normalBucket,
generation: '1',
metageneration: '2',
name: 'foo/bar/baz.png',
size: '10',
timeCreated: 'This is a real time',
updated: 'Also a real time',
md5Hash: 'deadbeef',
cacheControl: 'max-age=604800',
contentDisposition: 'Attachment; filename=baz.png',
contentLanguage: 'en-US',
contentType: 'application/jason',
downloadTokens: 'a,b,c',
metadata: { foo: 'bar' }
};
const serverResourceString = JSON.stringify(serverResource);
const metadataFromServerResource = fromResourceString(
storageService,
serverResourceString,
mappings
);
const downloadUrlFromServerResource =
`https://${DEFAULT_HOST}/v0/b/` +
normalBucket +
'/o/' +
encodeURIComponent(serverResource.name) +
'?alt=media&token=a';
function uploadMetadataString(name: string): string {
return JSON.stringify({
name,
contentType: contentTypeInMetadata,
metadata: { foo: 'bar' }
});
}
const metadataContentType = 'application/json; charset=utf-8';
async function assertBodyEquals(
body: Blob | string | Uint8Array | null,
expectedStr: string
): Promise<void> {
if (body === null) {
assert.fail('body was null');
}
if (typeof Blob !== 'undefined' && body instanceof Blob) {
return body
.text()
.then(str => {
assert.equal(str, expectedStr);
})
.catch(err => {
return Promise.reject(err);
});
} else if (body instanceof Uint8Array) {
const str = decodeUint8Array(body);
assert.equal(str, expectedStr);
} else {
assert.equal(body as string, expectedStr);
return Promise.resolve(undefined);
}
}
function checkMetadataHandler(
requestInfo: RequestInfo<string, Metadata>
): void {
const metadata = requestInfo.handler(fakeXhrIo({}), serverResourceString);
assert.deepEqual(metadata, metadataFromServerResource);
}
function checkNoOpHandler<T>(requestInfo: RequestInfo<string, T>): void {
try {
requestInfo.handler(fakeXhrIo({}), '');
} catch (e) {
assert.fail('Remove handler threw');
}
}
it('getMetadata request info', () => {
const maps: Array<[Location, string]> = [
[locationNormal, locationNormalUrl],
[locationEscapes, locationEscapesUrl]
];
for (const [location, url] of maps) {
const requestInfo = getMetadata(storageService, location, mappings);
assertObjectIncludes(
{
url: makeUrl(url, storageService.host, storageService._protocol),
method: 'GET',
body: null,
headers: {},
urlParams: {}
},
requestInfo
);
}
});
it('getMetadata handler', () => {
const requestInfo = getMetadata(storageService, locationNormal, mappings);
checkMetadataHandler(requestInfo);
});
it('list root request info', () => {
const requestInfo = list(storageService, locationRoot, '/');
assertObjectIncludes(
{
url: makeUrl(
locationNormalNoObjUrl,
storageService.host,
storageService._protocol
),
method: 'GET',
body: null,
headers: {},
urlParams: {
prefix: '',
delimiter: '/'
}
},
requestInfo
);
});
it('list request info', () => {
const maps: Array<[Location, string]> = [
[locationNormal, locationNormalNoObjUrl],
[locationEscapes, locationEscapesNoObjUrl]
];
const pageToken = 'pageToken-afeafeagef';
const maxResults = 13;
for (const [location, locationNoObjectUrl] of maps) {
const requestInfo = list(
storageService,
location,
'/',
pageToken,
maxResults
);
assertObjectIncludes(
{
url: makeUrl(
locationNoObjectUrl,
storageService.host,
storageService._protocol
),
method: 'GET',
body: null,
headers: {},
urlParams: {
prefix: location.path + '/',
delimiter: '/',
pageToken,
maxResults
}
},
requestInfo
);
}
});
it('list handler', () => {
const requestInfo = list(storageService, locationNormal);
const pageToken = 'YS9mLw==';
const listResponse = {
prefixes: ['a/f/'],
items: [
{
name: 'a/a',
bucket: normalBucket
},
{
name: 'a/b',
bucket: normalBucket
}
],
nextPageToken: pageToken
};
const listResponseString = JSON.stringify(listResponse);
const listResult = requestInfo.handler(fakeXhrIo({}), listResponseString);
assert.equal(listResult.prefixes[0].fullPath, 'a/f');
assert.equal(listResult.items[0].fullPath, 'a/a');
assert.equal(listResult.items[1].fullPath, 'a/b');
assert.equal(listResult.nextPageToken, pageToken);
});
it('list handler with custom bucket', () => {
const requestInfo = list(storageService, locationDifferentBucket);
const pageToken = 'YS9mLw==';
const listResponse = {
items: [
{
name: 'a/a',
bucket: differentBucket
}
],
nextPageToken: pageToken
};
const listResponseString = JSON.stringify(listResponse);
const listResult = requestInfo.handler(fakeXhrIo({}), listResponseString);
assert.equal(listResult.items[0].bucket, differentBucket);
});
it('getDownloadUrl request info', () => {
const maps: Array<[Location, string]> = [
[locationNormal, locationNormalUrl],
[locationEscapes, locationEscapesUrl]
];
for (const [location, url] of maps) {
const requestInfo = getDownloadUrl(storageService, location, mappings);
assertObjectIncludes(
{
url: makeUrl(url, storageService.host, storageService._protocol),
method: 'GET',
body: null,
headers: {},
urlParams: {}
},
requestInfo
);
}
});
it('getDownloadUrl handler', () => {
const requestInfo = getDownloadUrl(
storageService,
locationNormal,
mappings
);
const url = requestInfo.handler(fakeXhrIo({}), serverResourceString);
assert.equal(url, downloadUrlFromServerResource);
});
it('getBytes handler', () => {
const requestInfo = getBytes(storageService, locationNormal);
const bytes = requestInfo.handler(
fakeXhrIo({}),
new Uint8Array([1, 128, 255])
) as ArrayBuffer; // Narrow type to ArrayBuffer
assert.deepEqual(new Uint8Array(bytes), new Uint8Array([1, 128, 255]));
});
it('updateMetadata requestinfo', () => {
const maps = [
[locationNormal, locationNormalUrl],
[locationEscapes, locationEscapesUrl]
];
for (let i = 0; i < maps.length; i++) {
const location = maps[i][0] as Location;
const url = maps[i][1] as string;
const requestInfo = updateMetadata(
storageService,
location,
metadata,
mappings
);
assertObjectIncludes(
{
url: makeUrl(url, storageService.host, storageService._protocol),
method: 'PATCH',
body: metadataString,
headers: { 'Content-Type': metadataContentType },
urlParams: {}
},
requestInfo
);
}
});
it('updateMetadata handler', () => {
const requestInfo = updateMetadata(
storageService,
locationNormal,
metadata,
mappings
);
checkMetadataHandler(requestInfo);
});
it('deleteObject request info', () => {
const maps = [
[locationNormal, locationNormalUrl],
[locationEscapes, locationEscapesUrl]
];
for (let i = 0; i < maps.length; i++) {
const location = maps[i][0] as Location;
const url = maps[i][1] as string;
const requestInfo = deleteObject(storageService, location);
assertObjectIncludes(
{
url: makeUrl(url, storageService.host, storageService._protocol),
method: 'DELETE',
body: null,
headers: {},
urlParams: {}
},
requestInfo
);
}
});
it('deleteObject handler', () => {
const requestInfo = deleteObject(storageService, locationNormal);
checkNoOpHandler(requestInfo);
});
it('multipartUpload request info', () => {
const multipartHeaderRegex =
/^multipart\/related; boundary=([A-Za-z0-9]+)$/;
const maps = [
[locationNormal, locationNormalNoObjUrl],
[locationEscapes, locationEscapesNoObjUrl]
];
const promises: Array<Promise<void>> = [];
for (let i = 0; i < maps.length; i++) {
const location = maps[i][0] as Location;
const url = maps[i][1] as string;
const makeMultipartBodyString = (boundary: string): string => {
return (
'--' +
boundary +
'\r\n' +
'Content-Type: ' +
metadataContentType +
'\r\n\r\n' +
uploadMetadataString(location.path) +
'\r\n--' +
boundary +
'\r\n' +
'Content-Type: ' +
contentTypeInMetadata +
'\r\n\r\n' +
smallBlobString +
'\r\n--' +
boundary +
'--'
);
};
const requestInfo = multipartUpload(
storageService,
location,
mappings,
smallBlob,
metadata
);
const matches = (requestInfo.headers['Content-Type'] as string).match(
multipartHeaderRegex
);
assert.isNotNull(matches);
assert.equal(matches!.length, 2);
const boundary = matches![1];
promises.push(
assertBodyEquals(requestInfo.body, makeMultipartBodyString(boundary))
);
assertObjectIncludes(
{
url: makeUrl(url, storageService.host, storageService._protocol),
method: 'POST',
urlParams: { name: location.path },
headers: {
'X-Goog-Upload-Protocol': 'multipart',
// Checked before this block, but needed here because
// assertObjectIncludes does exact checks on values.
'Content-Type': requestInfo.headers['Content-Type']
}
},
requestInfo
);
}
return Promise.all(promises);
});
it('multipartUpload handler', () => {
const requestInfo = multipartUpload(
storageService,
locationNormal,
mappings,
smallBlob,
metadata
);
checkMetadataHandler(requestInfo);
});
it('createResumableUpload request info', () => {
const maps = [
[locationNormal, locationNormalNoObjUrl],
[locationEscapes, locationEscapesNoObjUrl]
];
const promises: Array<Promise<void>> = [];
for (let i = 0; i < maps.length; i++) {
const location = maps[i][0] as Location;
const url = maps[i][1] as string;
const requestInfo = createResumableUpload(
storageService,
location,
mappings,
smallBlob,
metadata
);
assertObjectIncludes(
{
url: makeUrl(url, storageService.host, storageService._protocol),
method: 'POST',
urlParams: { name: location.path },
headers: {
'X-Goog-Upload-Protocol': 'resumable',
'X-Goog-Upload-Command': 'start',
'X-Goog-Upload-Header-Content-Length': `${smallBlob.size()}`,
'X-Goog-Upload-Header-Content-Type': contentTypeInMetadata,
'Content-Type': metadataContentType
}
},
requestInfo
);
promises.push(
assertBodyEquals(requestInfo.body, uploadMetadataString(location.path))
);
}
return Promise.all(promises);
});
function _testCreateResumableUploadHandler(): void {
const requestInfo = createResumableUpload(
storageService,
locationNormal,
mappings,
smallBlob,
metadata
);
const uploadUrl = 'https://i.am.an.upload.url.com/hello/there';
const handlerUrl = requestInfo.handler(
fakeXhrIo({
'X-Goog-Upload-Status': 'active',
'X-Goog-Upload-URL': uploadUrl
}),
''
);
assert.equal(handlerUrl, uploadUrl);
}
it('getResumableUploadStatus request info', () => {
const url =
'https://this.is.totally.a.real.url.com/hello/upload?whatsgoingon';
const requestInfo = getResumableUploadStatus(
storageService,
locationNormal,
url,
smallBlob
);
assertObjectIncludes(
{
url,
method: 'POST',
urlParams: {},
headers: { 'X-Goog-Upload-Command': 'query' }
},
requestInfo
);
});
describe('getResumableUploadStatus handler', () => {
const url =
'https://this.is.totally.a.real.url.com/hello/upload?whatsgoingon';
const requestInfo = getResumableUploadStatus(
storageService,
locationNormal,
url,
smallBlob
);
let status = requestInfo.handler(
fakeXhrIo({
'X-Goog-Upload-Status': 'active',
'X-Goog-Upload-Size-Received': '0'
}),
''
);
let expectedStatus = new ResumableUploadStatus(0, smallBlob.size(), false);
assert.deepEqual(status, expectedStatus);
status = requestInfo.handler(
fakeXhrIo({
'X-Goog-Upload-Status': 'final',
'X-Goog-Upload-Size-Received': '' + smallBlob.size()
}),
''
);
expectedStatus = new ResumableUploadStatus(
smallBlob.size(),
smallBlob.size(),
true
);
assert.deepEqual(status, expectedStatus);
});
it('continueResumableUpload request info', () => {
const url =
'https://this.is.totally.a.real.url.com/hello/upload?whatsgoingon';
const requestInfo = continueResumableUpload(
locationNormal,
storageService,
url,
smallBlob,
RESUMABLE_UPLOAD_CHUNK_SIZE,
mappings
);
assertObjectIncludes(
{
url,
method: 'POST',
urlParams: {},
headers: {
'X-Goog-Upload-Command': 'upload, finalize',
'X-Goog-Upload-Offset': '0'
}
},
requestInfo
);
return assertBodyEquals(requestInfo.body, smallBlobString);
});
it('populates requestInfo with the upload command when more chunks are left', () => {
const url =
'https://this.is.totally.a.real.url.com/hello/upload?whatsgoingon';
const requestInfo = continueResumableUpload(
locationNormal,
storageService,
url,
bigBlob,
RESUMABLE_UPLOAD_CHUNK_SIZE,
mappings
);
assertObjectIncludes(
{
url,
method: 'POST',
urlParams: {},
headers: {
'X-Goog-Upload-Command': 'upload',
'X-Goog-Upload-Offset': '0'
}
},
requestInfo
);
assert.deepEqual(
requestInfo.body,
bigBlob.slice(0, RESUMABLE_UPLOAD_CHUNK_SIZE)!.uploadData()
);
});
it('populates requestInfo with just the finalize command when no more data needs to be uploaded', () => {
const url =
'https://this.is.totally.a.real.url.com/hello/upload?whatsgoingon';
const blobSize = bigBlob.size();
const requestInfo = continueResumableUpload(
locationNormal,
storageService,
url,
bigBlob,
RESUMABLE_UPLOAD_CHUNK_SIZE,
mappings,
{
current: blobSize,
total: blobSize,
finalized: false,
metadata: null
}
);
assertObjectIncludes(
{
url,
method: 'POST',
urlParams: {},
headers: {
'X-Goog-Upload-Command': 'finalize',
'X-Goog-Upload-Offset': blobSize.toString()
}
},
requestInfo
);
assert.deepEqual(
requestInfo.body,
bigBlob.slice(blobSize, blobSize)!.uploadData()
);
});
it('continueResumableUpload handler', () => {
const url =
'https://this.is.totally.a.real.url.com/hello/upload?whatsgoingon';
const chunkSize = RESUMABLE_UPLOAD_CHUNK_SIZE;
assert.isTrue(smallBlob.size() < chunkSize);
let requestInfo = continueResumableUpload(
locationNormal,
storageService,
url,
smallBlob,
chunkSize,
mappings
);
let status = requestInfo.handler(
fakeXhrIo({ 'X-Goog-Upload-Status': 'final' }),
serverResourceString
);
let expectedStatus = new ResumableUploadStatus(
smallBlob.size(),
smallBlob.size(),
true,
metadataFromServerResource
);
assert.deepEqual(status, expectedStatus);
assert.isTrue(bigBlob.size() > chunkSize);
requestInfo = continueResumableUpload(
locationNormal,
storageService,
url,
bigBlob,
chunkSize,
mappings
);
status = requestInfo.handler(
fakeXhrIo({ 'X-Goog-Upload-Status': 'active' }),
''
);
expectedStatus = new ResumableUploadStatus(
chunkSize,
bigBlob.size(),
false
);
assert.deepEqual(status, expectedStatus);
});
it('error handler passes through unknown errors', () => {
const requestInfo = getMetadata(storageService, locationNormal, mappings);
const error = unknown();
const resultError = requestInfo.errorHandler!(fakeXhrIo({}, 509), error);
assert.equal(resultError, error);
});
it('error handler converts 404 to not found', () => {
const requestInfo = getMetadata(storageService, locationNormal, mappings);
const error = unknown();
const resultError = requestInfo.errorHandler!(fakeXhrIo({}, 404), error);
assert.isTrue(resultError._codeEquals(StorageErrorCode.OBJECT_NOT_FOUND));
});
it('error handler converts 402 to quota exceeded', () => {
const requestInfo = getMetadata(storageService, locationNormal, mappings);
const error = unknown();
const resultError = requestInfo.errorHandler!(fakeXhrIo({}, 402), error);
assert.isTrue(resultError._codeEquals(StorageErrorCode.QUOTA_EXCEEDED));
});
});