/* * Copyright 2022 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. */ package com.google.android.libraries.mobiledatadownload.file; import static com.google.android.libraries.mobiledatadownload.file.common.testing.FragmentParamMatchers.eqParam; import static com.google.common.truth.Truth.assertThat; import static org.junit.Assert.assertThrows; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.atLeast; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.eq; import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoMoreInteractions; import static org.mockito.Mockito.when; import android.content.Context; import android.net.Uri; import androidx.test.core.app.ApplicationProvider; import com.google.android.libraries.mobiledatadownload.file.backends.AndroidFileBackend; import com.google.android.libraries.mobiledatadownload.file.backends.JavaFileBackend; import com.google.android.libraries.mobiledatadownload.file.common.GcParam; import com.google.android.libraries.mobiledatadownload.file.common.UnsupportedFileStorageOperation; import com.google.android.libraries.mobiledatadownload.file.common.testing.BufferingMonitor; import com.google.android.libraries.mobiledatadownload.file.common.testing.FileStorageTestBase; import com.google.android.libraries.mobiledatadownload.file.common.testing.NoOpMonitor; import com.google.android.libraries.mobiledatadownload.file.openers.AppendStreamOpener; import com.google.android.libraries.mobiledatadownload.file.openers.ReadStreamOpener; import com.google.android.libraries.mobiledatadownload.file.openers.WriteStreamOpener; import com.google.android.libraries.mobiledatadownload.file.spi.Backend; import com.google.android.libraries.mobiledatadownload.file.spi.ForwardingBackend; import com.google.android.libraries.mobiledatadownload.file.spi.Transform; import com.google.android.libraries.mobiledatadownload.file.transforms.BufferTransform; import com.google.android.libraries.mobiledatadownload.file.transforms.CompressTransform; import com.google.common.collect.ImmutableList; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.util.Arrays; import java.util.Collections; import java.util.Date; import org.junit.Test; import org.junit.runner.RunWith; import org.mockito.InOrder; import org.robolectric.RobolectricTestRunner; /** * Test {@link com.google.android.libraries.mobiledatadownload.file.SynchronousFileStorage}. These * tests use mocks and basically just ensure that things are being called in the expected order. */ @RunWith(RobolectricTestRunner.class) public class SynchronousFileStorageTest extends FileStorageTestBase { private SynchronousFileStorage storage; private final Context context = ApplicationProvider.getApplicationContext(); @Override protected void initStorage() { storage = new SynchronousFileStorage( ImmutableList.of(fileBackend, cnsBackend), ImmutableList.of(compressTransform, encryptTransform, identityTransform), ImmutableList.of(countingMonitor)); } // Backend registrar @Test public void registeredBackends_shouldNotThrowException() throws Exception { assertThat(storage.exists(file1Uri)).isFalse(); } @Test public void unregisteredBackends_shouldThrowException() throws Exception { Uri unregisteredUri = Uri.parse("unregistered:///"); assertThrows(UnsupportedFileStorageOperation.class, () -> storage.exists(unregisteredUri)); } @Test public void nullUriScheme_shouldThrowException() throws Exception { Uri relativeUri = Uri.parse("/relative/uri"); assertThrows(UnsupportedFileStorageOperation.class, () -> storage.exists(relativeUri)); } @Test public void emptyBackendName_shouldBeSilentlySkipped() throws Exception { Backend emptyNameBackend = new ForwardingBackend() { @Override protected Backend delegate() { return fileBackend; } @Override public String name() { return ""; } }; var unused = new SynchronousFileStorage(ImmutableList.of(emptyNameBackend)); } @Test public void doubleRegisteringBackendName_shouldThrowException() throws Exception { assertThrows( IllegalArgumentException.class, () -> new SynchronousFileStorage( ImmutableList.of(new JavaFileBackend(), new JavaFileBackend()))); } // Backend operations @Test public void deleteFile_shouldInvokeBackend() throws Exception { storage.deleteFile(file1Uri); verify(fileBackend).deleteFile(file1Uri); } @Test public void deleteDir_shouldInvokeBackend() throws Exception { storage.deleteDirectory(file1Uri); verify(fileBackend).deleteDirectory(file1Uri); } @Test public void deleteRecursively_shouldRecurse() throws Exception { Uri dir2Uri = dir1Uri.buildUpon().appendPath("dir2").build(); when(fileBackend.exists(dir1Uri)).thenReturn(true); when(fileBackend.isDirectory(dir1Uri)).thenReturn(true); when(fileBackend.exists(dir2Uri)).thenReturn(true); when(fileBackend.isDirectory(dir2Uri)).thenReturn(true); when(fileBackend.exists(file1Uri)).thenReturn(true); when(fileBackend.exists(file2Uri)).thenReturn(true); when(fileBackend.children(dir1Uri)).thenReturn(ImmutableList.of(file1Uri, file2Uri, dir2Uri)); when(fileBackend.children(dir2Uri)).thenReturn(Collections.emptyList()); assertThat(storage.deleteRecursively(dir1Uri)).isTrue(); verify(fileBackend).deleteFile(file1Uri); verify(fileBackend).deleteFile(file2Uri); verify(fileBackend).deleteDirectory(dir2Uri); verify(fileBackend).deleteDirectory(dir1Uri); } @Test public void deleteRecursively_failsOnAccessError() throws Exception { when(fileBackend.exists(dir1Uri)).thenReturn(true); when(fileBackend.isDirectory(dir1Uri)).thenReturn(true); when(fileBackend.exists(file1Uri)).thenReturn(true); when(fileBackend.exists(file2Uri)).thenReturn(true); when(fileBackend.children(dir1Uri)).thenReturn(ImmutableList.of(file1Uri, file2Uri)); doThrow(IOException.class).when(fileBackend).deleteFile(file2Uri); assertThrows(IOException.class, () -> storage.deleteRecursively(dir1Uri)); verify(fileBackend).deleteFile(file1Uri); verify(fileBackend).deleteFile(file2Uri); verify(fileBackend, never()).deleteDirectory(dir1Uri); } @Test public void deleteRecursively_fileDeletes() throws Exception { when(fileBackend.exists(file1Uri)).thenReturn(true); when(fileBackend.isDirectory(file1Uri)).thenReturn(false); assertThat(storage.deleteRecursively(file1Uri)).isTrue(); verify(fileBackend).exists(file1Uri); verify(fileBackend).deleteFile(file1Uri); } @Test public void deleteRecursively_fileNotExist() throws Exception { when(fileBackend.exists(dir1Uri)).thenReturn(false); assertThat(storage.deleteRecursively(dir1Uri)).isFalse(); verify(fileBackend).exists(dir1Uri); } @Test public void rename_shouldInvokeBackend() throws Exception { storage.rename(file1Uri, file2Uri); verify(fileBackend).rename(file1Uri, file2Uri); } @Test public void rename_crossingBackendsShouldThrowException() throws Exception { assertThrows(UnsupportedFileStorageOperation.class, () -> storage.rename(file1Uri, cnsUri)); } @Test public void exists_shouldInvokeBackend() throws Exception { assertThat(storage.exists(file1Uri)).isFalse(); verify(fileBackend).exists(file1Uri); } @Test public void isDirectory_shouldInvokeBackend() throws Exception { assertThat(storage.isDirectory(file1Uri)).isFalse(); verify(fileBackend).isDirectory(file1Uri); } @Test public void createDirectoryshouldInvokeBackend() throws Exception { storage.createDirectory(file1Uri); verify(fileBackend).createDirectory(file1Uri); } @Test public void fileSize_shouldInvokeBackend() throws Exception { assertThat(storage.fileSize(file1Uri)).isEqualTo(0L); verify(fileBackend).fileSize(file1Uri); } // // Transform stuff // @Test public void registeredTransforms_shouldNotThrowException() throws Exception { assertThat(storage.exists(file1CompressUri)).isFalse(); verify(fileBackend).exists(file1Uri); } @Test public void unregisteredTransforms_shouldThrowException() throws Exception { Uri unregisteredUri = Uri.parse(file1Uri + "#transform=unregistered"); assertThrows(UnsupportedFileStorageOperation.class, () -> storage.exists(unregisteredUri)); } @Test public void getDebugInfo_shouldIncludeRegisteredPlugins() throws Exception { SynchronousFileStorage debugStorage = new SynchronousFileStorage( ImmutableList.of(new JavaFileBackend(), AndroidFileBackend.builder(context).build()), ImmutableList.of(new CompressTransform(), new BufferTransform()), ImmutableList.of(new BufferingMonitor(), new NoOpMonitor())); String debugString = debugStorage.getDebugInfo(); assertThat(debugString) .isEqualTo( "Registered Mobstore Plugins:\n" + "\n" + "Backends:\n" + "protocol: android, class: AndroidFileBackend,\n" + "protocol: file, class: JavaFileBackend\n" + "\n" + "Transforms:\n" + "BufferTransform,\n" + "CompressTransform\n" + "\n" + "Monitors:\n" + "BufferingMonitor,\n" + "NoOpMonitor"); } @Test public void emptyTransformName_shouldBeSilentlySkipped() throws Exception { Transform emptyNameTransform = new Transform() { @Override public String name() { return ""; } }; var unused = new SynchronousFileStorage(ImmutableList.of(), ImmutableList.of(emptyNameTransform)); } @Test public void doubleRegisteringTransformName_shouldThrowException() throws Exception { assertThrows( IllegalArgumentException.class, () -> new SynchronousFileStorage( ImmutableList.of(), ImmutableList.of(new CompressTransform(), new CompressTransform()))); } @Test public void read_shouldInvokeTransforms() throws Exception { when(compressTransform.wrapForRead(eqParam(uriWithCompressParam), any(InputStream.class))) .thenReturn(compressInputStream); try (InputStream in = storage.open(file1CompressUri, ReadStreamOpener.create())) { verify(compressTransform).wrapForRead(eqParam(uriWithCompressParam), any(InputStream.class)); verify(compressTransform).encode(eqParam(uriWithCompressParam), eq(file1Filename)); } } @Test public void read_shouldInvokeTransformsWithEncoded() throws Exception { when(compressTransform.wrapForRead( eqParam(uriWithCompressParamWithEncoded), any(InputStream.class))) .thenReturn(compressInputStream); try (InputStream in = storage.open(file1CompressUriWithEncoded, ReadStreamOpener.create())) { verify(compressTransform) .wrapForRead(eqParam(uriWithCompressParamWithEncoded), any(InputStream.class)); verify(compressTransform).encode(eqParam(uriWithCompressParamWithEncoded), eq(file1Filename)); } } @Test public void write_shouldInvokeTransforms() throws Exception { when(compressTransform.wrapForWrite(eqParam(uriWithCompressParam), any(OutputStream.class))) .thenReturn(compressOutputStream); try (OutputStream out = storage.open(file1CompressUri, WriteStreamOpener.create())) { verify(compressTransform) .wrapForWrite(eqParam(uriWithCompressParam), any(OutputStream.class)); verify(compressTransform).encode(eqParam(uriWithCompressParam), eq(file1Filename)); } } @Test public void deleteFile_shouldInvokeTransformEncode() throws Exception { storage.deleteFile(file1CompressUri); verify(compressTransform).encode(eqParam(uriWithCompressParam), eq(file1Filename)); } @Test public void deleteDirectory_shouldNOTInvokeTransformEncode() throws Exception { storage.deleteDirectory(file1CompressUri); verify(compressTransform, never()).encode(eqParam(uriWithCompressParam), any()); } @Test public void rename_shouldInvokeTransformEncode() throws Exception { storage.rename(file1CompressUri, file2CompressEncryptUri); verify(compressTransform).encode(eqParam(uriWithCompressParam), eq(file1Filename)); verify(encryptTransform).encode(eqParam(uriWithEncryptParam), eq(file2Filename)); verify(compressTransform).encode(eqParam(uriWithCompressParam), eq(file2Filename)); } @Test public void rename_shouldNOTInvokeTransformEncodeOnDirectory() throws Exception { storage.rename(dir1Uri, dir2CompressUri); verify(compressTransform, never()).encode(eqParam(uriWithCompressParam), any()); } @Test public void exists_shouldInvokeTransformEncode() throws Exception { assertThat(storage.exists(file1CompressUri)).isFalse(); verify(compressTransform).encode(eqParam(uriWithCompressParam), eq(file1Filename)); } @Test public void exists_shouldNOTInvokeTransformEncodeOnDirectory() throws Exception { assertThat(storage.exists(dir2CompressUri)).isFalse(); verify(compressTransform, never()).encode(eqParam(uriWithCompressParam), any()); } @Test public void isDirectory_shouldNOTInvokeTransformEncode() throws Exception { assertThat(storage.isDirectory(file1CompressUri)).isFalse(); verify(compressTransform, never()).encode(eqParam(uriWithCompressParam), any()); } @Test public void createDirectoryshouldNOTInvokeTransformEncode() throws Exception { storage.createDirectory(file1CompressUri); verify(compressTransform, never()).encode(eqParam(uriWithCompressParam), any()); } @Test public void fileSize_shouldInvokeTransformEncode() throws Exception { assertThat(storage.fileSize(file1CompressUri)).isEqualTo(0L); verify(compressTransform).encode(eqParam(uriWithCompressParam), eq(file1Filename)); } @Test public void multipleTransformsShouldBeEncodedForwardAndComposedInReverse() throws Exception { // The spec "transform=compress+encrypt" means the data is compressed and then // encrypted before stored. Since transforms are implemented by wrapping transforms, // they need to be instantiated in the reverse order. So, in this case, // 1. encrypt is instantiated // 2. encrypt wraps the backend stream // 3. compress is instantiated // 4. compress wraps the encrypted stream // 5. the compress transforms stream is returned to the client // In contrast, encode() is called in the order in which transforms appear in the fragment. when(encryptTransform.wrapForWrite(eqParam(uriWithEncryptParam), any(OutputStream.class))) .thenReturn(encryptOutputStream); when(compressTransform.wrapForWrite(eqParam(uriWithCompressParam), eq(encryptOutputStream))) .thenReturn(compressOutputStream); try (OutputStream out = storage.open(file2CompressEncryptUri, WriteStreamOpener.create())) { InOrder forward = inOrder(compressTransform, encryptTransform); forward.verify(compressTransform).encode(eqParam(uriWithCompressParam), eq(file2Filename)); forward.verify(encryptTransform).encode(eqParam(uriWithEncryptParam), eq(file2Filename)); InOrder reverse = inOrder(encryptTransform, compressTransform); reverse .verify(encryptTransform) .wrapForWrite(eqParam(uriWithEncryptParam), any(OutputStream.class)); reverse .verify(compressTransform) .wrapForWrite(eqParam(uriWithCompressParam), eq(encryptOutputStream)); } } @Test public void children_shouldInvokeTransformDecodeInReverse() throws Exception { // The spec "transform=compress+encrypt" means the data is compressed and then encrypted. // When listing children, transform decodes() are invoked in reverse. when(fileBackend.children(eq(file2Uri))).thenReturn(Arrays.asList(Uri.parse("file:///child1"))); assertThat(storage.children(file2CompressEncryptUri)).isNotNull(); InOrder reverse = inOrder(encryptTransform, compressTransform); reverse.verify(encryptTransform).decode(eqParam(uriWithEncryptParam), eq("child1")); reverse.verify(compressTransform).decode(eqParam(uriWithCompressParam), eq("child1")); } @Test public void children_transformsShouldNotDecodeSubdirectories() throws Exception { when(fileBackend.children(eq(file1Uri))) .thenReturn( Arrays.asList( Uri.parse("file:///file1"), Uri.parse("file:///file2"), Uri.parse("file:///dir1/"))); assertThat(storage.children(file1CompressUri)).isNotNull(); verify(compressTransform).decode(eqParam(uriWithCompressParam), eq("file1")); verify(compressTransform).decode(eqParam(uriWithCompressParam), eq("file2")); verify(compressTransform, never()).decode(eqParam(uriWithCompressParam), eq("dir1")); verify(compressTransform, atLeast(1)).name(); verifyNoMoreInteractions(compressTransform); } // // Monitor stuff // @Test public void read_shouldMonitor() throws Exception { try (InputStream in = storage.open(file1Uri, ReadStreamOpener.create())) { verify(countingMonitor).monitorRead(file1Uri); } } @Test public void write_shouldMonitor() throws Exception { try (OutputStream out = storage.open(file1Uri, WriteStreamOpener.create())) { verify(countingMonitor).monitorWrite(file1Uri); } } @Test public void append_shouldMonitor() throws Exception { try (OutputStream out = storage.open(file1Uri, AppendStreamOpener.create())) { verify(countingMonitor).monitorAppend(file1Uri); } } @Test public void readWithTransform_shouldGetOriginalUri() throws Exception { try (InputStream in = storage.open(file1CompressUri, ReadStreamOpener.create())) { verify(countingMonitor).monitorRead(file1CompressUri); } } // // MobStoreGc stuff // @Test public void gcMethods_shouldInvokeCorrespondingBackendMethods() throws Exception { GcParam param = GcParam.expiresAt(new Date(1L)); storage.setGcParam(file1Uri, param); verify(fileBackend).setGcParam(eq(file1Uri), eq(param)); storage.getGcParam(file1Uri); verify(fileBackend).getGcParam(eq(file1Uri)); } }