Provide an endpoint to upgrade plugin (#2624)

#### What type of PR is this?

/kind feature
/area core
/milestone 2.0

#### What this PR does / why we need it:

Provide an endpoint to upgrade plugin by uploading a new jar file.

#### Which issue(s) this PR fixes:

Fixes https://github.com/halo-dev/halo/issues/2551

#### Special notes for your reviewer:

1. Install an old plugin
2. Update the plugin and package again
3. Upgrade the plugin and see the result

#### Does this PR introduce a user-facing change?

```release-note
提供插件更新功能
```
pull/2632/head
John Niang 2022-10-26 12:02:10 +08:00 committed by GitHub
parent 3ec7e31cac
commit d9cb6bf732
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 487 additions and 116 deletions

View File

@ -1,8 +1,10 @@
package run.halo.app.core.extension.endpoint; package run.halo.app.core.extension.endpoint;
import static java.nio.file.Files.copy;
import static java.util.Comparator.comparing; import static java.util.Comparator.comparing;
import static org.springdoc.core.fn.builders.apiresponse.Builder.responseBuilder; 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.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.requestbody.Builder.requestBodyBuilder;
import static org.springdoc.core.fn.builders.schema.Builder.schemaBuilder; import static org.springdoc.core.fn.builders.schema.Builder.schemaBuilder;
import static org.springframework.boot.convert.ApplicationConversionService.getSharedInstance; 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.ListResult.generateGenericClass;
import static run.halo.app.extension.router.QueryParamBuildUtil.buildParametersFromType; import static run.halo.app.extension.router.QueryParamBuildUtil.buildParametersFromType;
import static run.halo.app.extension.router.selector.SelectorUtil.labelAndFieldSelectorToPredicate; 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.ArraySchema;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import java.io.IOException; import java.io.IOException;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.nio.file.Paths; import java.nio.file.Paths;
import java.time.Duration;
import java.util.Comparator; import java.util.Comparator;
import java.util.Objects; import java.util.Objects;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Predicate; import java.util.function.Predicate;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springdoc.webflux.core.fn.SpringdocRouteBuilder; import org.springdoc.webflux.core.fn.SpringdocRouteBuilder;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.data.domain.Sort; import org.springframework.data.domain.Sort;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.http.codec.multipart.FilePart; import org.springframework.http.codec.multipart.FilePart;
import org.springframework.http.codec.multipart.Part; import org.springframework.http.codec.multipart.Part;
import org.springframework.retry.RetryException;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.util.MultiValueMap; import org.springframework.util.MultiValueMap;
import org.springframework.util.StringUtils; import org.springframework.util.StringUtils;
import org.springframework.web.reactive.function.server.RouterFunction; import org.springframework.web.reactive.function.server.RouterFunction;
import org.springframework.web.reactive.function.server.ServerRequest; import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse; 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.ServerWebExchange;
import org.springframework.web.server.ServerWebInputException; import org.springframework.web.server.ServerWebInputException;
import reactor.core.Exceptions;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers; import reactor.core.scheduler.Schedulers;
import reactor.util.retry.Retry;
import run.halo.app.core.extension.Plugin; import run.halo.app.core.extension.Plugin;
import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.extension.router.IListRequest.QueryListRequest; import run.halo.app.extension.router.IListRequest.QueryListRequest;
@ -73,6 +82,16 @@ public class PluginEndpoint implements CustomEndpoint {
)) ))
.response(responseBuilder().implementation(Plugin.class)) .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 -> { .GET("plugins", this::list, builder -> {
builder.operationId("ListPlugins") builder.operationId("ListPlugins")
.tag(tag) .tag(tag)
@ -83,6 +102,78 @@ public class PluginEndpoint implements CustomEndpoint {
.build(); .build();
} }
private Mono<ServerResponse> upgrade(ServerRequest request) {
var pluginNameInPath = request.pathVariable("name");
var tempDirRef = new AtomicReference<Path>();
var tempPluginPathRef = new AtomicReference<Path>();
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<Plugin> deletePluginAndWaitForComplete(String pluginName) {
return client.fetch(Plugin.class, pluginName)
.flatMap(client::delete)
.flatMap(plugin -> waitForDeleted(plugin.getMetadata().getName()).thenReturn(plugin));
}
private Mono<Void> 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<Path> createTempDirectory() {
return Mono.fromCallable(() -> Files.createTempDirectory("halo-plugin-"))
.subscribeOn(Schedulers.boundedElastic());
}
public static class ListRequest extends QueryListRequest { public static class ListRequest extends QueryListRequest {
private final ServerWebExchange exchange; private final ServerWebExchange exchange;
@ -109,7 +200,7 @@ public class PluginEndpoint implements CustomEndpoint {
+ "creationTimestamp"), + "creationTimestamp"),
schema = @Schema(description = "like field,asc or field,desc", schema = @Schema(description = "like field,asc or field,desc",
implementation = String.class, implementation = String.class,
example = "creationtimestamp,desc")) example = "creationTimestamp,desc"))
public Sort getSort() { public Sort getSort() {
return SortResolver.defaultInstance.resolve(exchange); return SortResolver.defaultInstance.resolve(exchange);
} }
@ -183,8 +274,7 @@ public class PluginEndpoint implements CustomEndpoint {
} }
Mono<ServerResponse> install(ServerRequest request) { Mono<ServerResponse> install(ServerRequest request) {
return request.bodyToMono(new ParameterizedTypeReference<MultiValueMap<String, Part>>() { return request.multipartData()
})
.flatMap(this::getJarFilePart) .flatMap(this::getJarFilePart)
.flatMap(this::transferToTemp) .flatMap(this::transferToTemp)
.flatMap(tempJarFilePath -> { .flatMap(tempJarFilePath -> {

View File

@ -6,13 +6,19 @@ import java.io.Closeable;
import java.io.IOException; import java.io.IOException;
import java.nio.file.CopyOption; import java.nio.file.CopyOption;
import java.nio.file.DirectoryNotEmptyException; import java.nio.file.DirectoryNotEmptyException;
import java.nio.file.FileVisitResult;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.nio.file.Paths; import java.nio.file.Paths;
import java.nio.file.SimpleFileVisitor;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.function.Consumer; import java.util.function.Consumer;
import java.util.jar.JarEntry;
import java.util.jar.JarOutputStream;
import java.util.stream.Stream; import java.util.stream.Stream;
import java.util.zip.ZipEntry; import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream; import java.util.zip.ZipInputStream;
import java.util.zip.ZipOutputStream;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.lang.NonNull; import org.springframework.lang.NonNull;
import org.springframework.util.Assert; 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. * Creates directories if absent.
* *

View File

@ -15,7 +15,7 @@ rules:
- apiGroups: [ "plugin.halo.run" ] - apiGroups: [ "plugin.halo.run" ]
resources: [ "plugins" ] resources: [ "plugins" ]
verbs: [ "create", "patch", "update", "delete", "deletecollection" ] 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" ] verbs: [ "create" ]
--- ---
apiVersion: v1alpha1 apiVersion: v1alpha1

View File

@ -1,29 +1,49 @@
package run.halo.app.core.extension.endpoint; package run.halo.app.core.extension.endpoint;
import static java.util.Objects.requireNonNull;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.ArgumentMatchers.same; 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.verify;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
import static org.springframework.test.web.reactive.server.WebTestClient.bindToRouterFunction; 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.time.Instant;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Objects; 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.Test;
import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks; import org.mockito.InjectMocks;
import org.mockito.Mock; import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension; 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 reactor.core.publisher.Mono;
import run.halo.app.core.extension.Plugin; import run.halo.app.core.extension.Plugin;
import run.halo.app.extension.ListResult; import run.halo.app.extension.ListResult;
import run.halo.app.extension.Metadata; import run.halo.app.extension.Metadata;
import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.infra.utils.FileUtils;
import run.halo.app.plugin.PluginProperties; import run.halo.app.plugin.PluginProperties;
@Slf4j
@ExtendWith(MockitoExtension.class) @ExtendWith(MockitoExtension.class)
class PluginEndpointTest { class PluginEndpointTest {
@ -36,127 +56,249 @@ class PluginEndpointTest {
@InjectMocks @InjectMocks
PluginEndpoint endpoint; PluginEndpoint endpoint;
@Test @Nested
void shouldListEmptyPluginsWhenNoPlugins() { class PluginListTest {
when(client.list(same(Plugin.class), any(), any(), anyInt(), anyInt()))
.thenReturn(Mono.just(ListResult.emptyResult()));
bindToRouterFunction(endpoint.endpoint()) @Test
.build() void shouldListEmptyPluginsWhenNoPlugins() {
.get().uri("/plugins") when(client.list(same(Plugin.class), any(), any(), anyInt(), anyInt()))
.exchange() .thenReturn(Mono.just(ListResult.emptyResult()));
.expectStatus().isOk()
.expectBody() bindToRouterFunction(endpoint.endpoint())
.jsonPath("$.items.length()").isEqualTo(0) .build()
.jsonPath("$.total").isEqualTo(0); .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 @Nested
void shouldListPluginsWhenPluginPresent() { class PluginUpgradeTest {
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()) WebTestClient webClient;
.build()
.get().uri("/plugins")
.exchange()
.expectStatus().isOk()
.expectBody()
.jsonPath("$.items.length()").isEqualTo(3)
.jsonPath("$.total").isEqualTo(3);
}
@Test Path tempDirectory;
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()) Path plugin002;
.build()
.get().uri("/plugins?keyword=Expected")
.exchange()
.expectStatus().isOk();
verify(client).list(same(Plugin.class), argThat( @BeforeEach
predicate -> predicate.test(expectPlugin) void setUp() throws URISyntaxException, IOException {
&& !predicate.test(unexpectedPlugin1) webClient = WebTestClient.bindToRouterFunction(endpoint.endpoint())
&& !predicate.test(unexpectedPlugin2)), .build();
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()) tempDirectory = Files.createTempDirectory("halo-test-plugin-upgrade-");
.build() lenient().when(pluginProperties.getPluginsRoot())
.get().uri("/plugins?enabled=true") .thenReturn(tempDirectory.resolve("plugins").toString());
.exchange() plugin002 = tempDirectory.resolve("plugin-0.0.2.jar");
.expectStatus().isOk();
verify(client).list(same(Plugin.class), argThat( var plugin002Uri = requireNonNull(
predicate -> predicate.test(expectPlugin) getClass().getClassLoader().getResource("plugin/plugin-0.0.2")).toURI();
&& !predicate.test(unexpectedPlugin1)
&& !predicate.test(unexpectedPlugin2)),
any(), anyInt(), anyInt());
}
@Test FileUtils.jar(Paths.get(plugin002Uri), tempDirectory.resolve("plugin-0.0.2.jar"));
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()) @AfterEach
.build() void cleanUp() {
.get().uri("/plugins?sort=creationTimestamp,desc") FileUtils.deleteRecursivelyAndSilently(tempDirectory);
.exchange() }
.expectStatus().isOk();
@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.<Plugin>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) { Plugin createPlugin(String name) {

View File

@ -1,13 +1,14 @@
package run.halo.app.infra.utils; package run.halo.app.infra.utils;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
class FileNameUtilsTest { class FileNameUtilsTest {
@Test @Test
public void shouldNotRemovExtIfNoExt() { public void shouldNotRemoveExtIfNoExt() {
assertEquals("halo", FileNameUtils.removeFileExtension("halo", true)); assertEquals("halo", FileNameUtils.removeFileExtension("halo", true));
assertEquals("halo", FileNameUtils.removeFileExtension("halo", false)); assertEquals("halo", FileNameUtils.removeFileExtension("halo", false));
} }
@ -41,4 +42,10 @@ class FileNameUtilsTest {
assertEquals(".halo", FileNameUtils.removeFileExtension(".halo.tar.gz", true)); assertEquals(".halo", FileNameUtils.removeFileExtension(".halo.tar.gz", true));
assertEquals(".halo.tar", FileNameUtils.removeFileExtension(".halo.tar.gz", false)); assertEquals(".halo.tar", FileNameUtils.removeFileExtension(".halo.tar.gz", false));
} }
@Test
void shouldReturnNullIfFilenameIsNull() {
assertNull(FileNameUtils.removeFileExtension(null, true));
assertNull(FileNameUtils.removeFileExtension(null, false));
}
} }

View File

@ -1,8 +1,23 @@
package run.halo.app.infra.utils; 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 org.junit.jupiter.api.Assertions.assertThrows;
import static run.halo.app.infra.utils.FileUtils.checkDirectoryTraversal; 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.Nested;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import run.halo.app.infra.exception.AccessDeniedException; 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")));
}
}
} }

View File

@ -2,7 +2,6 @@ package run.halo.app.security;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times; import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
@ -65,7 +64,7 @@ class DefaultUserDetailServiceTest {
void shouldReturnErrorWhenFailedToUpdatePassword() { void shouldReturnErrorWhenFailedToUpdatePassword() {
var fakeUser = createFakeUserDetails(); var fakeUser = createFakeUserDetails();
var exception = mock(RuntimeException.class); var exception = new RuntimeException("failed to update password");
when(userService.updatePassword("faker", "new-fake-password")).thenReturn( when(userService.updatePassword("faker", "new-fake-password")).thenReturn(
Mono.error(exception) Mono.error(exception)
); );

View File

@ -0,0 +1 @@
Here is an example file.

View File

@ -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