diff --git a/src/main/java/run/halo/app/core/extension/endpoint/PluginEndpoint.java b/src/main/java/run/halo/app/core/extension/endpoint/PluginEndpoint.java index d4df3335a..e19636514 100644 --- a/src/main/java/run/halo/app/core/extension/endpoint/PluginEndpoint.java +++ b/src/main/java/run/halo/app/core/extension/endpoint/PluginEndpoint.java @@ -1,8 +1,10 @@ package run.halo.app.core.extension.endpoint; +import static java.nio.file.Files.copy; import static java.util.Comparator.comparing; import static org.springdoc.core.fn.builders.apiresponse.Builder.responseBuilder; import static org.springdoc.core.fn.builders.content.Builder.contentBuilder; +import static org.springdoc.core.fn.builders.parameter.Builder.parameterBuilder; import static org.springdoc.core.fn.builders.requestbody.Builder.requestBodyBuilder; import static org.springdoc.core.fn.builders.schema.Builder.schemaBuilder; import static org.springframework.boot.convert.ApplicationConversionService.getSharedInstance; @@ -10,33 +12,40 @@ import static org.springframework.web.reactive.function.server.RequestPredicates import static run.halo.app.extension.ListResult.generateGenericClass; import static run.halo.app.extension.router.QueryParamBuildUtil.buildParametersFromType; import static run.halo.app.extension.router.selector.SelectorUtil.labelAndFieldSelectorToPredicate; +import static run.halo.app.infra.utils.FileUtils.deleteRecursivelyAndSilently; +import io.swagger.v3.oas.annotations.enums.ParameterIn; import io.swagger.v3.oas.annotations.media.ArraySchema; import io.swagger.v3.oas.annotations.media.Schema; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; +import java.time.Duration; import java.util.Comparator; import java.util.Objects; +import java.util.concurrent.atomic.AtomicReference; import java.util.function.Predicate; import lombok.extern.slf4j.Slf4j; import org.springdoc.webflux.core.fn.SpringdocRouteBuilder; -import org.springframework.core.ParameterizedTypeReference; import org.springframework.data.domain.Sort; import org.springframework.http.MediaType; import org.springframework.http.codec.multipart.FilePart; import org.springframework.http.codec.multipart.Part; +import org.springframework.retry.RetryException; import org.springframework.stereotype.Component; import org.springframework.util.MultiValueMap; import org.springframework.util.StringUtils; import org.springframework.web.reactive.function.server.RouterFunction; import org.springframework.web.reactive.function.server.ServerRequest; import org.springframework.web.reactive.function.server.ServerResponse; +import org.springframework.web.server.ServerErrorException; import org.springframework.web.server.ServerWebExchange; import org.springframework.web.server.ServerWebInputException; +import reactor.core.Exceptions; import reactor.core.publisher.Mono; import reactor.core.scheduler.Schedulers; +import reactor.util.retry.Retry; import run.halo.app.core.extension.Plugin; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.extension.router.IListRequest.QueryListRequest; @@ -73,6 +82,16 @@ public class PluginEndpoint implements CustomEndpoint { )) .response(responseBuilder().implementation(Plugin.class)) ) + .POST("plugins/{name}/upgrade", contentType(MediaType.MULTIPART_FORM_DATA), + this::upgrade, builder -> builder.operationId("UpgradePlugin") + .description("Upgrade a plugin by uploading a Jar file") + .tag(tag) + .parameter(parameterBuilder().name("name").in(ParameterIn.PATH).required(true)) + .requestBody(requestBodyBuilder() + .required(true) + .content(contentBuilder().mediaType(MediaType.MULTIPART_FORM_DATA_VALUE) + .schema(schemaBuilder().implementation(InstallRequest.class)))) + ) .GET("plugins", this::list, builder -> { builder.operationId("ListPlugins") .tag(tag) @@ -83,6 +102,78 @@ public class PluginEndpoint implements CustomEndpoint { .build(); } + private Mono upgrade(ServerRequest request) { + var pluginNameInPath = request.pathVariable("name"); + var tempDirRef = new AtomicReference(); + var tempPluginPathRef = new AtomicReference(); + return client.fetch(Plugin.class, pluginNameInPath) + .switchIfEmpty(Mono.error(() -> new ServerWebInputException( + "The given plugin with name " + pluginNameInPath + " does not exit"))) + .then(request.multipartData()) + .flatMap(this::getJarFilePart) + .flatMap(jarFilePart -> createTempDirectory() + .doOnNext(tempDirRef::set) + .flatMap(tempDirectory -> { + var pluginPath = tempDirectory.resolve(jarFilePart.filename()); + return jarFilePart.transferTo(pluginPath).thenReturn(pluginPath); + })) + .doOnNext(tempPluginPathRef::set) + .map(pluginPath -> new YamlPluginFinder().find(pluginPath)) + .doOnNext(newPlugin -> { + // validate the plugin name + if (!Objects.equals(pluginNameInPath, newPlugin.getMetadata().getName())) { + throw new ServerWebInputException( + "The uploaded plugin doesn't match the given plugin name"); + } + }) + .flatMap(newPlugin -> deletePluginAndWaitForComplete(newPlugin.getMetadata().getName()) + .map(oldPlugin -> { + var enabled = oldPlugin.getSpec().getEnabled(); + newPlugin.getSpec().setEnabled(enabled); + return newPlugin; + }) + ) + .publishOn(Schedulers.boundedElastic()) + .doOnNext(newPlugin -> { + // copy the Jar file into plugin root + try { + var pluginRoot = Paths.get(pluginProperties.getPluginsRoot()); + createDirectoriesIfNotExists(pluginRoot); + var tempPluginPath = tempPluginPathRef.get(); + var filename = tempPluginPath.getFileName().toString(); + copy(tempPluginPath, pluginRoot.resolve(newPlugin.generateFileName())); + } catch (IOException e) { + throw Exceptions.propagate(e); + } + }) + .flatMap(client::create) + .flatMap(newPlugin -> ServerResponse.ok().bodyValue(newPlugin)) + .doFinally(signalType -> deleteRecursivelyAndSilently(tempDirRef.get())); + } + + private Mono deletePluginAndWaitForComplete(String pluginName) { + return client.fetch(Plugin.class, pluginName) + .flatMap(client::delete) + .flatMap(plugin -> waitForDeleted(plugin.getMetadata().getName()).thenReturn(plugin)); + } + + private Mono waitForDeleted(String pluginName) { + return client.fetch(Plugin.class, pluginName) + .flatMap(plugin -> Mono.error( + new RetryException("Re-check if the plugin is deleted successfully"))) + .retryWhen(Retry.fixedDelay(20, Duration.ofMillis(100)) + .filter(t -> t instanceof RetryException) + ) + .onErrorMap(Exceptions::isRetryExhausted, + t -> new ServerErrorException("Wait timeout for plugin deleted", t)) + .then(); + } + + private Mono createTempDirectory() { + return Mono.fromCallable(() -> Files.createTempDirectory("halo-plugin-")) + .subscribeOn(Schedulers.boundedElastic()); + } + public static class ListRequest extends QueryListRequest { private final ServerWebExchange exchange; @@ -109,7 +200,7 @@ public class PluginEndpoint implements CustomEndpoint { + "creationTimestamp"), schema = @Schema(description = "like field,asc or field,desc", implementation = String.class, - example = "creationtimestamp,desc")) + example = "creationTimestamp,desc")) public Sort getSort() { return SortResolver.defaultInstance.resolve(exchange); } @@ -183,8 +274,7 @@ public class PluginEndpoint implements CustomEndpoint { } Mono install(ServerRequest request) { - return request.bodyToMono(new ParameterizedTypeReference>() { - }) + return request.multipartData() .flatMap(this::getJarFilePart) .flatMap(this::transferToTemp) .flatMap(tempJarFilePath -> { diff --git a/src/main/java/run/halo/app/infra/utils/FileUtils.java b/src/main/java/run/halo/app/infra/utils/FileUtils.java index f0a7028cb..8df1f4275 100644 --- a/src/main/java/run/halo/app/infra/utils/FileUtils.java +++ b/src/main/java/run/halo/app/infra/utils/FileUtils.java @@ -6,13 +6,19 @@ import java.io.Closeable; import java.io.IOException; import java.nio.file.CopyOption; import java.nio.file.DirectoryNotEmptyException; +import java.nio.file.FileVisitResult; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; +import java.nio.file.SimpleFileVisitor; +import java.nio.file.attribute.BasicFileAttributes; import java.util.function.Consumer; +import java.util.jar.JarEntry; +import java.util.jar.JarOutputStream; import java.util.stream.Stream; import java.util.zip.ZipEntry; import java.util.zip.ZipInputStream; +import java.util.zip.ZipOutputStream; import lombok.extern.slf4j.Slf4j; import org.springframework.lang.NonNull; import org.springframework.util.Assert; @@ -65,6 +71,42 @@ public abstract class FileUtils { } } + public static void zip(Path sourcePath, Path targetPath) throws IOException { + try (var zos = new ZipOutputStream(Files.newOutputStream(targetPath))) { + Files.walkFileTree(sourcePath, new SimpleFileVisitor<>() { + @Override + public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) + throws IOException { + checkDirectoryTraversal(sourcePath, file); + var relativePath = sourcePath.relativize(file); + var entry = new ZipEntry(relativePath.toString()); + zos.putNextEntry(entry); + Files.copy(file, zos); + zos.closeEntry(); + return super.visitFile(file, attrs); + } + }); + } + } + + public static void jar(Path sourcePath, Path targetPath) throws IOException { + try (var jos = new JarOutputStream(Files.newOutputStream(targetPath))) { + Files.walkFileTree(sourcePath, new SimpleFileVisitor<>() { + @Override + public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) + throws IOException { + checkDirectoryTraversal(sourcePath, file); + var relativePath = sourcePath.relativize(file); + var entry = new JarEntry(relativePath.toString()); + jos.putNextEntry(entry); + Files.copy(file, jos); + jos.closeEntry(); + return super.visitFile(file, attrs); + } + }); + } + } + /** * Creates directories if absent. * diff --git a/src/main/resources/extensions/role-template-plugin.yaml b/src/main/resources/extensions/role-template-plugin.yaml index 0edc23b9f..57900aa5a 100644 --- a/src/main/resources/extensions/role-template-plugin.yaml +++ b/src/main/resources/extensions/role-template-plugin.yaml @@ -15,7 +15,7 @@ rules: - apiGroups: [ "plugin.halo.run" ] resources: [ "plugins" ] verbs: [ "create", "patch", "update", "delete", "deletecollection" ] - - nonResourceURLs: [ "/apis/api.console.halo.run/v1alpha1/plugins/install" ] + - nonResourceURLs: [ "/apis/api.console.halo.run/v1alpha1/plugins/*" ] verbs: [ "create" ] --- apiVersion: v1alpha1 diff --git a/src/test/java/run/halo/app/core/extension/endpoint/PluginEndpointTest.java b/src/test/java/run/halo/app/core/extension/endpoint/PluginEndpointTest.java index fe39ef1b3..c9bff1ff2 100644 --- a/src/test/java/run/halo/app/core/extension/endpoint/PluginEndpointTest.java +++ b/src/test/java/run/halo/app/core/extension/endpoint/PluginEndpointTest.java @@ -1,29 +1,49 @@ package run.halo.app.core.extension.endpoint; +import static java.util.Objects.requireNonNull; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.ArgumentMatchers.same; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.springframework.test.web.reactive.server.WebTestClient.bindToRouterFunction; +import static org.springframework.web.reactive.function.BodyInserters.fromMultipartData; +import java.io.IOException; +import java.net.URISyntaxException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; import java.time.Instant; import java.util.ArrayList; import java.util.List; import java.util.Objects; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.core.io.FileSystemResource; +import org.springframework.http.MediaType; +import org.springframework.http.client.MultipartBodyBuilder; +import org.springframework.test.web.reactive.server.WebTestClient; import reactor.core.publisher.Mono; import run.halo.app.core.extension.Plugin; import run.halo.app.extension.ListResult; import run.halo.app.extension.Metadata; import run.halo.app.extension.ReactiveExtensionClient; +import run.halo.app.infra.utils.FileUtils; import run.halo.app.plugin.PluginProperties; +@Slf4j @ExtendWith(MockitoExtension.class) class PluginEndpointTest { @@ -36,127 +56,249 @@ class PluginEndpointTest { @InjectMocks PluginEndpoint endpoint; - @Test - void shouldListEmptyPluginsWhenNoPlugins() { - when(client.list(same(Plugin.class), any(), any(), anyInt(), anyInt())) - .thenReturn(Mono.just(ListResult.emptyResult())); + @Nested + class PluginListTest { - bindToRouterFunction(endpoint.endpoint()) - .build() - .get().uri("/plugins") - .exchange() - .expectStatus().isOk() - .expectBody() - .jsonPath("$.items.length()").isEqualTo(0) - .jsonPath("$.total").isEqualTo(0); + @Test + void shouldListEmptyPluginsWhenNoPlugins() { + when(client.list(same(Plugin.class), any(), any(), anyInt(), anyInt())) + .thenReturn(Mono.just(ListResult.emptyResult())); + + bindToRouterFunction(endpoint.endpoint()) + .build() + .get().uri("/plugins") + .exchange() + .expectStatus().isOk() + .expectBody() + .jsonPath("$.items.length()").isEqualTo(0) + .jsonPath("$.total").isEqualTo(0); + } + + @Test + void shouldListPluginsWhenPluginPresent() { + var plugins = List.of( + createPlugin("fake-plugin-1"), + createPlugin("fake-plugin-2"), + createPlugin("fake-plugin-3") + ); + var expectResult = new ListResult<>(plugins); + when(client.list(same(Plugin.class), any(), any(), anyInt(), anyInt())) + .thenReturn(Mono.just(expectResult)); + + bindToRouterFunction(endpoint.endpoint()) + .build() + .get().uri("/plugins") + .exchange() + .expectStatus().isOk() + .expectBody() + .jsonPath("$.items.length()").isEqualTo(3) + .jsonPath("$.total").isEqualTo(3); + } + + @Test + void shouldFilterPluginsWhenKeywordProvided() { + var expectPlugin = + createPlugin("fake-plugin-2", "expected display name", "", false); + var unexpectedPlugin1 = + createPlugin("fake-plugin-1", "first fake display name", "", false); + var unexpectedPlugin2 = + createPlugin("fake-plugin-3", "second fake display name", "", false); + var plugins = List.of( + expectPlugin + ); + var expectResult = new ListResult<>(plugins); + when(client.list(same(Plugin.class), any(), any(), anyInt(), anyInt())) + .thenReturn(Mono.just(expectResult)); + + bindToRouterFunction(endpoint.endpoint()) + .build() + .get().uri("/plugins?keyword=Expected") + .exchange() + .expectStatus().isOk(); + + verify(client).list(same(Plugin.class), argThat( + predicate -> predicate.test(expectPlugin) + && !predicate.test(unexpectedPlugin1) + && !predicate.test(unexpectedPlugin2)), + any(), anyInt(), anyInt()); + } + + @Test + void shouldFilterPluginsWhenEnabledProvided() { + var expectPlugin = + createPlugin("fake-plugin-2", "expected display name", "", true); + var unexpectedPlugin1 = + createPlugin("fake-plugin-1", "first fake display name", "", false); + var unexpectedPlugin2 = + createPlugin("fake-plugin-3", "second fake display name", "", false); + var plugins = List.of( + expectPlugin + ); + var expectResult = new ListResult<>(plugins); + when(client.list(same(Plugin.class), any(), any(), anyInt(), anyInt())) + .thenReturn(Mono.just(expectResult)); + + bindToRouterFunction(endpoint.endpoint()) + .build() + .get().uri("/plugins?enabled=true") + .exchange() + .expectStatus().isOk(); + + verify(client).list(same(Plugin.class), argThat( + predicate -> predicate.test(expectPlugin) + && !predicate.test(unexpectedPlugin1) + && !predicate.test(unexpectedPlugin2)), + any(), anyInt(), anyInt()); + } + + @Test + void shouldSortPluginsWhenCreationTimestampSet() { + var expectPlugin = + createPlugin("fake-plugin-2", "expected display name", "", true); + var unexpectedPlugin1 = + createPlugin("fake-plugin-1", "first fake display name", "", false); + var unexpectedPlugin2 = + createPlugin("fake-plugin-3", "second fake display name", "", false); + var expectResult = new ListResult<>(List.of(expectPlugin)); + when(client.list(same(Plugin.class), any(), any(), anyInt(), anyInt())) + .thenReturn(Mono.just(expectResult)); + + bindToRouterFunction(endpoint.endpoint()) + .build() + .get().uri("/plugins?sort=creationTimestamp,desc") + .exchange() + .expectStatus().isOk(); + + verify(client).list(same(Plugin.class), any(), argThat(comparator -> { + var now = Instant.now(); + var plugins = new ArrayList<>(List.of( + createPlugin("fake-plugin-a", now), + createPlugin("fake-plugin-b", now.plusSeconds(1)), + createPlugin("fake-plugin-c", now.plusSeconds(2)) + )); + plugins.sort(comparator); + return Objects.deepEquals(plugins, List.of( + createPlugin("fake-plugin-c", now.plusSeconds(2)), + createPlugin("fake-plugin-b", now.plusSeconds(1)), + createPlugin("fake-plugin-a", now) + )); + }), anyInt(), anyInt()); + } } - @Test - void shouldListPluginsWhenPluginPresent() { - var plugins = List.of( - createPlugin("fake-plugin-1"), - createPlugin("fake-plugin-2"), - createPlugin("fake-plugin-3") - ); - var expectResult = new ListResult<>(plugins); - when(client.list(same(Plugin.class), any(), any(), anyInt(), anyInt())) - .thenReturn(Mono.just(expectResult)); + @Nested + class PluginUpgradeTest { - bindToRouterFunction(endpoint.endpoint()) - .build() - .get().uri("/plugins") - .exchange() - .expectStatus().isOk() - .expectBody() - .jsonPath("$.items.length()").isEqualTo(3) - .jsonPath("$.total").isEqualTo(3); - } + WebTestClient webClient; - @Test - void shouldFilterPluginsWhenKeywordProvided() { - var expectPlugin = - createPlugin("fake-plugin-2", "expected display name", "", false); - var unexpectedPlugin1 = createPlugin("fake-plugin-1", "first fake display name", "", false); - var unexpectedPlugin2 = - createPlugin("fake-plugin-3", "second fake display name", "", false); - var plugins = List.of( - expectPlugin - ); - var expectResult = new ListResult<>(plugins); - when(client.list(same(Plugin.class), any(), any(), anyInt(), anyInt())) - .thenReturn(Mono.just(expectResult)); + Path tempDirectory; - bindToRouterFunction(endpoint.endpoint()) - .build() - .get().uri("/plugins?keyword=Expected") - .exchange() - .expectStatus().isOk(); + Path plugin002; - verify(client).list(same(Plugin.class), argThat( - predicate -> predicate.test(expectPlugin) - && !predicate.test(unexpectedPlugin1) - && !predicate.test(unexpectedPlugin2)), - any(), anyInt(), anyInt()); - } + @BeforeEach + void setUp() throws URISyntaxException, IOException { + webClient = WebTestClient.bindToRouterFunction(endpoint.endpoint()) + .build(); - @Test - void shouldFilterPluginsWhenEnabledProvided() { - var expectPlugin = - createPlugin("fake-plugin-2", "expected display name", "", true); - var unexpectedPlugin1 = createPlugin("fake-plugin-1", "first fake display name", "", false); - var unexpectedPlugin2 = - createPlugin("fake-plugin-3", "second fake display name", "", false); - var plugins = List.of( - expectPlugin - ); - var expectResult = new ListResult<>(plugins); - when(client.list(same(Plugin.class), any(), any(), anyInt(), anyInt())) - .thenReturn(Mono.just(expectResult)); - bindToRouterFunction(endpoint.endpoint()) - .build() - .get().uri("/plugins?enabled=true") - .exchange() - .expectStatus().isOk(); + tempDirectory = Files.createTempDirectory("halo-test-plugin-upgrade-"); + lenient().when(pluginProperties.getPluginsRoot()) + .thenReturn(tempDirectory.resolve("plugins").toString()); + plugin002 = tempDirectory.resolve("plugin-0.0.2.jar"); - verify(client).list(same(Plugin.class), argThat( - predicate -> predicate.test(expectPlugin) - && !predicate.test(unexpectedPlugin1) - && !predicate.test(unexpectedPlugin2)), - any(), anyInt(), anyInt()); - } + var plugin002Uri = requireNonNull( + getClass().getClassLoader().getResource("plugin/plugin-0.0.2")).toURI(); - @Test - void shouldSortPluginsWhenCreationTimestampSet() { - var expectPlugin = - createPlugin("fake-plugin-2", "expected display name", "", true); - var unexpectedPlugin1 = createPlugin("fake-plugin-1", "first fake display name", "", false); - var unexpectedPlugin2 = - createPlugin("fake-plugin-3", "second fake display name", "", false); - var expectResult = new ListResult<>(List.of(expectPlugin)); - when(client.list(same(Plugin.class), any(), any(), anyInt(), anyInt())) - .thenReturn(Mono.just(expectResult)); + FileUtils.jar(Paths.get(plugin002Uri), tempDirectory.resolve("plugin-0.0.2.jar")); + } - bindToRouterFunction(endpoint.endpoint()) - .build() - .get().uri("/plugins?sort=creationTimestamp,desc") - .exchange() - .expectStatus().isOk(); + @AfterEach + void cleanUp() { + FileUtils.deleteRecursivelyAndSilently(tempDirectory); + } + + @Test + void shouldResponseBadRequestIfNoPluginInstalledBefore() { + var bodyBuilder = new MultipartBodyBuilder(); + bodyBuilder.part("file", new FileSystemResource(plugin002)) + .contentType(MediaType.MULTIPART_FORM_DATA); + + when(client.fetch(Plugin.class, "fake-plugin")).thenReturn(Mono.empty()); + + webClient.post().uri("/plugins/fake-plugin/upgrade") + .contentType(MediaType.MULTIPART_FORM_DATA) + .body(fromMultipartData(bodyBuilder.build())) + .exchange() + .expectStatus().isBadRequest(); + + verify(client).fetch(Plugin.class, "fake-plugin"); + verify(client, never()).delete(any(Plugin.class)); + verify(client, never()).create(any(Plugin.class)); + } + + @Test + void shouldWaitTimeoutIfOldPluginCannotBeDeleted() { + var bodyBuilder = new MultipartBodyBuilder(); + bodyBuilder.part("file", new FileSystemResource(plugin002)) + .contentType(MediaType.MULTIPART_FORM_DATA); + + var oldPlugin = createPlugin("fake-plugin"); + when(client.fetch(Plugin.class, "fake-plugin")) + // for first check + .thenReturn(Mono.just(oldPlugin)) + // for deleting check + .thenReturn(Mono.just(oldPlugin)) + // for waiting + .thenReturn(Mono.just(oldPlugin)); + + when(client.delete(oldPlugin)).thenReturn(Mono.just(oldPlugin)); + + webClient.post().uri("/plugins/fake-plugin/upgrade") + .contentType(MediaType.MULTIPART_FORM_DATA) + .body(fromMultipartData(bodyBuilder.build())) + .exchange() + .expectStatus().is5xxServerError(); + + verify(client, times(3)).fetch(Plugin.class, "fake-plugin"); + verify(client).delete(oldPlugin); + verify(client, never()).create(any(Plugin.class)); + } + + @Test + void shouldBeOkIfPluginInstalledBefore() { + var bodyBuilder = new MultipartBodyBuilder(); + bodyBuilder.part("file", new FileSystemResource(plugin002)) + .contentType(MediaType.MULTIPART_FORM_DATA); + + var oldPlugin = createPlugin("fake-plugin"); + when(client.fetch(Plugin.class, "fake-plugin")) + // for first check + .thenReturn(Mono.just(oldPlugin)) + // for deleting check + .thenReturn(Mono.just(oldPlugin)) + // for waiting + .thenReturn(Mono.empty()); + + when(client.delete(oldPlugin)).thenReturn(Mono.just(oldPlugin)); + + Plugin newPlugin = createPlugin("fake-plugin", Instant.now()); + when(client.create( + argThat(plugin -> "0.0.2".equals(plugin.getSpec().getVersion())))) + .thenReturn(Mono.just(newPlugin)); + + webClient.post().uri("/plugins/fake-plugin/upgrade") + .contentType(MediaType.MULTIPART_FORM_DATA) + .body(fromMultipartData(bodyBuilder.build())) + .exchange() + .expectStatus().isOk() + .expectBody(Plugin.class) + .isEqualTo(newPlugin); + + verify(client, times(3)).fetch(Plugin.class, "fake-plugin"); + verify(client).delete(oldPlugin); + verify(client).create(any(Plugin.class)); + } - verify(client).list(same(Plugin.class), any(), argThat(comparator -> { - var now = Instant.now(); - var plugins = new ArrayList<>(List.of( - createPlugin("fake-plugin-a", now), - createPlugin("fake-plugin-b", now.plusSeconds(1)), - createPlugin("fake-plugin-c", now.plusSeconds(2)) - )); - plugins.sort(comparator); - return Objects.deepEquals(plugins, List.of( - createPlugin("fake-plugin-c", now.plusSeconds(2)), - createPlugin("fake-plugin-b", now.plusSeconds(1)), - createPlugin("fake-plugin-a", now) - )); - }), anyInt(), anyInt()); } Plugin createPlugin(String name) { diff --git a/src/test/java/run/halo/app/infra/utils/FileNameUtilsTest.java b/src/test/java/run/halo/app/infra/utils/FileNameUtilsTest.java index db4b589e8..ded4d54f0 100644 --- a/src/test/java/run/halo/app/infra/utils/FileNameUtilsTest.java +++ b/src/test/java/run/halo/app/infra/utils/FileNameUtilsTest.java @@ -1,13 +1,14 @@ package run.halo.app.infra.utils; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; import org.junit.jupiter.api.Test; class FileNameUtilsTest { @Test - public void shouldNotRemovExtIfNoExt() { + public void shouldNotRemoveExtIfNoExt() { assertEquals("halo", FileNameUtils.removeFileExtension("halo", true)); assertEquals("halo", FileNameUtils.removeFileExtension("halo", false)); } @@ -41,4 +42,10 @@ class FileNameUtilsTest { assertEquals(".halo", FileNameUtils.removeFileExtension(".halo.tar.gz", true)); assertEquals(".halo.tar", FileNameUtils.removeFileExtension(".halo.tar.gz", false)); } + + @Test + void shouldReturnNullIfFilenameIsNull() { + assertNull(FileNameUtils.removeFileExtension(null, true)); + assertNull(FileNameUtils.removeFileExtension(null, false)); + } } \ No newline at end of file diff --git a/src/test/java/run/halo/app/infra/utils/FileUtilsTest.java b/src/test/java/run/halo/app/infra/utils/FileUtilsTest.java index b63661f1a..95c972d50 100644 --- a/src/test/java/run/halo/app/infra/utils/FileUtilsTest.java +++ b/src/test/java/run/halo/app/infra/utils/FileUtilsTest.java @@ -1,8 +1,23 @@ package run.halo.app.infra.utils; +import static java.util.Objects.requireNonNull; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; import static run.halo.app.infra.utils.FileUtils.checkDirectoryTraversal; +import static run.halo.app.infra.utils.FileUtils.deleteRecursivelyAndSilently; +import static run.halo.app.infra.utils.FileUtils.jar; +import static run.halo.app.infra.utils.FileUtils.unzip; +import static run.halo.app.infra.utils.FileUtils.zip; +import java.io.IOException; +import java.net.URISyntaxException; +import java.nio.file.Files; +import java.nio.file.NoSuchFileException; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.zip.ZipInputStream; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import run.halo.app.infra.exception.AccessDeniedException; @@ -30,4 +45,64 @@ class FileUtilsTest { } } + + @Nested + class ZipTest { + + Path tempDirectory; + + @BeforeEach + void setUp() throws IOException { + tempDirectory = Files.createTempDirectory("halo-test-fileutils-zip-"); + } + + @AfterEach + void cleanUp() { + deleteRecursivelyAndSilently(tempDirectory); + } + + @Test + void zipFolderAndUnzip() throws IOException, URISyntaxException { + var uri = requireNonNull(getClass().getClassLoader().getResource("folder-to-zip")) + .toURI(); + var zipPath = tempDirectory.resolve("example.zip"); + zip(Paths.get(uri), zipPath); + + var unzipTarget = tempDirectory.resolve("example-folder"); + try (var zis = new ZipInputStream(Files.newInputStream(zipPath))) { + unzip(zis, unzipTarget); + } + + var content = Files.readString(unzipTarget.resolve("examplefile")); + assertEquals("Here is an example file.\n", content); + } + + @Test + void jarFolderAndUnzip() throws IOException, URISyntaxException { + var uri = requireNonNull(getClass().getClassLoader().getResource("folder-to-zip")) + .toURI(); + var zipPath = tempDirectory.resolve("example.zip"); + jar(Paths.get(uri), zipPath); + + var unzipTarget = tempDirectory.resolve("example-folder"); + try (var zis = new ZipInputStream(Files.newInputStream(zipPath))) { + unzip(zis, unzipTarget); + } + + var content = Files.readString(unzipTarget.resolve("examplefile")); + assertEquals("Here is an example file.\n", content); + } + + @Test + void zipFolderIfNoSuchFolder() { + assertThrows(NoSuchFileException.class, () -> + zip(Paths.get("no-such-folder"), tempDirectory.resolve("example.zip"))); + } + + @Test + void jarFolderIfNoSuchFolder() { + assertThrows(NoSuchFileException.class, () -> + jar(Paths.get("no-such-folder"), tempDirectory.resolve("example.zip"))); + } + } } \ No newline at end of file diff --git a/src/test/java/run/halo/app/security/DefaultUserDetailServiceTest.java b/src/test/java/run/halo/app/security/DefaultUserDetailServiceTest.java index 6f72a2953..736459730 100644 --- a/src/test/java/run/halo/app/security/DefaultUserDetailServiceTest.java +++ b/src/test/java/run/halo/app/security/DefaultUserDetailServiceTest.java @@ -2,7 +2,6 @@ package run.halo.app.security; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.mock; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -65,7 +64,7 @@ class DefaultUserDetailServiceTest { void shouldReturnErrorWhenFailedToUpdatePassword() { var fakeUser = createFakeUserDetails(); - var exception = mock(RuntimeException.class); + var exception = new RuntimeException("failed to update password"); when(userService.updatePassword("faker", "new-fake-password")).thenReturn( Mono.error(exception) ); diff --git a/src/test/resources/folder-to-zip/examplefile b/src/test/resources/folder-to-zip/examplefile new file mode 100644 index 000000000..bd82b20e6 --- /dev/null +++ b/src/test/resources/folder-to-zip/examplefile @@ -0,0 +1 @@ +Here is an example file. diff --git a/src/test/resources/plugin/plugin-0.0.2/plugin.yaml b/src/test/resources/plugin/plugin-0.0.2/plugin.yaml new file mode 100644 index 000000000..618438e76 --- /dev/null +++ b/src/test/resources/plugin/plugin-0.0.2/plugin.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Plugin +metadata: + name: fake-plugin +spec: + # 'version' is a valid semantic version string (see semver.org). + version: 0.0.2 + requires: ">=2.0.0" + author: johnniang + logo: https://halo.run/avatar + homepage: https://github.com/halo-sigs/halo-plugin-1 + displayName: "Fake Display Name" + description: "Fake description" + license: + - name: GPLv3