From a30563e3391c67602fe3b020cb401c00dab7bccc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jordi=20G=C3=B3mez?= Date: Thu, 30 Jul 2026 10:01:51 +0200 Subject: [PATCH 1/2] feat: build the registry from the upstream server library New server/ Gradle build: it consumes eclipse-openvsx/openvsx's server project as a library through a composite build over the upstream submodule (pinned to the paired branch, -PopenvsxServerPath overrides it), with upstream's RegistryApplication as main class and the Spring Boot version derived from the upstream version catalog. The Docker image builds the bootJar itself on a plain JRE base, replicating the previous upstream-derived image layout; Helm charts, ESO secrets and DEPLOYMENT_CONFIG are unchanged. runServer works like upstream's. --- .dockerignore | 7 + .gitignore | 3 + .gitmodules | 4 + Dockerfile | 55 +++- server/build.gradle | 98 +++++++ server/gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 48462 bytes .../gradle/wrapper/gradle-wrapper.properties | 9 + server/gradlew | 248 ++++++++++++++++++ server/gradlew.bat | 82 ++++++ server/settings.gradle | 36 +++ server/upstream | 1 + 11 files changed, 538 insertions(+), 5 deletions(-) create mode 100644 .dockerignore create mode 100644 .gitmodules create mode 100644 server/build.gradle create mode 100644 server/gradle/wrapper/gradle-wrapper.jar create mode 100644 server/gradle/wrapper/gradle-wrapper.properties create mode 100755 server/gradlew create mode 100644 server/gradlew.bat create mode 100644 server/settings.gradle create mode 160000 server/upstream diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..2038611ce --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +.git +server/.gradle +server/build +server/bin +server/upstream +website/node_modules +website/dist diff --git a/.gitignore b/.gitignore index 8656a4cb4..3845ae385 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,6 @@ .helm charts/openvsx/charts +.gradle +build/ +bin/ diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 000000000..31c1cc9f0 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,4 @@ +[submodule "server/upstream"] + path = server/upstream + url = https://github.com/gnugomez/openvsx.git + branch = poc/eclipse-extraction diff --git a/Dockerfile b/Dockerfile index 2a0d0040b..f02d5a0e7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -ARG SERVER_VERSION=1e8f4f6 +ARG SERVER_VERSION=poc/eclipse-extraction ARG SERVER_VERSION_STRING=v1.1.0-dev.3 # Builder image to compile the website @@ -26,15 +26,60 @@ RUN cd website \ && yarn install --immutable \ && yarn build -# Main image derived from openvsx-server -FROM ghcr.io/eclipse-openvsx/openvsx-server-snapshot:${SERVER_VERSION} +# Upstream server sources at the ref given by SERVER_VERSION, consumed as a library +# by the Gradle build stage below. To build from a local checkout instead of cloning +# (e.g. the 'upstream' submodule or a sibling working copy): +# docker build --build-context server-src=server/upstream . +FROM alpine/git:latest AS server-clone +ARG SERVER_REPO=https://github.com/gnugomez/openvsx.git ARG SERVER_VERSION +RUN git clone --filter=blob:none ${SERVER_REPO} /src \ + && git -C /src checkout ${SERVER_VERSION} + +FROM scratch AS server-src +COPY --from=server-clone /src / + +# Build the server application against the upstream library (composite build) +FROM eclipse-temurin:25-jdk AS server-builder + +WORKDIR /workdir + +COPY --from=server-src / upstream/ +COPY server/gradlew server/settings.gradle server/build.gradle ./ +COPY server/gradle/ gradle/ +COPY server/src/ src/ + +ENV CI=true + +RUN ./gradlew --no-daemon -PopenvsxServerPath=upstream/server bootJar \ + && mkdir exploded \ + && cd exploded \ + && jar -xf ../build/libs/openvsx-server.jar + +# Main image: plain JRE plus the exploded server archive, replicating the layout of +# the upstream-derived image this used to build FROM +FROM eclipse-temurin:25-jre ARG SERVER_VERSION_STRING +# Create user openvsx and set up home directory +RUN groupadd -r openvsx \ + && useradd --no-log-init -r -g openvsx openvsx \ + && mkdir -p /home/openvsx/server \ + && chown -R openvsx:openvsx /home/openvsx + +USER openvsx +WORKDIR /home/openvsx/server + +COPY --chown=openvsx:openvsx --from=server-builder /workdir/exploded/ ./ +COPY --chown=openvsx:openvsx --from=server-src /server/scripts/run-server.sh ./ + COPY --from=builder --chown=openvsx:openvsx /workdir/website/dist/ BOOT-INF/classes/static/ COPY --from=builder --chown=openvsx:openvsx /workdir/configuration/application.yml config/ COPY --from=builder --chown=openvsx:openvsx /workdir/configuration/logback-spring.xml BOOT-INF/classes/ COPY --from=builder --chown=openvsx:openvsx /workdir/mail-templates BOOT-INF/classes/mail-templates -# Replace version placeholder with arg value -RUN sed -i "s//${SERVER_VERSION_STRING}/g" config/application.yml +# Replace version placeholder with arg value; make the start script executable +RUN chmod u+x run-server.sh \ + && sed -i "s//${SERVER_VERSION_STRING}/g" config/application.yml + +ENTRYPOINT ["./run-server.sh"] diff --git a/server/build.gradle b/server/build.gradle new file mode 100644 index 000000000..049e386ea --- /dev/null +++ b/server/build.gradle @@ -0,0 +1,98 @@ +plugins { + id 'java' + id 'org.springframework.boot' +} + +group = 'org.eclipsefdn.openvsx' + +java { + sourceCompatibility = gradle.ext.javaVersion +} + +repositories { + mavenCentral() +} + +dependencies { + // The upstream registry server, consumed as a library. No version: the composite + // build (settings.gradle) substitutes the included server project. + implementation 'org.eclipse.openvsx:openvsx-server' + + // Mirrors the server's tomcat -> jetty replacement; component module rules are + // project-local upstream and do not propagate to consumers. + modules { + module("org.springframework.boot:spring-boot-starter-tomcat") { + replacedBy("org.springframework.boot:spring-boot-starter-jetty") + } + } +} + +// The server's io.spring.dependency-management plugin resolves its graph with +// Maven-like semantics: managed and declared versions beat newer transitive +// requests. That does not travel to consumers, where Gradle picks the highest +// requested version. Force the few modules where the two disagree so this build +// resolves exactly what the upstream bootJar ships (the two ranges are upstream's +// CVE floors). Verified by diffing BOOT-INF/lib against an upstream bootJar; see +// NOTES.md. The mockito/byte-buddy entries only matter on the test classpaths +// (upstream's tests force-downgrade them the same way). +configurations.configureEach { + resolutionStrategy { + force 'com.google.code.gson:gson:2.13.2' + force 'com.fasterxml.woodstox:woodstox-core:6.4.0' + force 'org.apache.commons:commons-compress:[1.26.0,2.0)' + force 'org.eclipse.parsson:parsson:[1.1.8,2.0)' + force 'net.bytebuddy:byte-buddy:1.17.8' + force 'org.mockito:mockito-core:5.20.0' + force 'org.mockito:mockito-junit-jupiter:5.20.0' + } +} + +// Same developer entry point as upstream's `./gradlew runServer`: runs the registry +// on the host JVM with upstream's dev configuration (src/dev/resources), plus this +// module's beans. Expects the dev services from upstream's docker-compose.yml +// (postgres), exactly like the upstream task does. +tasks.register('runServer', JavaExec) { + jvmArgs = [ + '-Dorg.jooq.no-logo=true', + '-Dorg.jooq.no-tips=true', + '--enable-native-access=ALL-UNNAMED' // due to https://github.com/netty/netty/issues/15161 + ] + classpath = sourceSets.main.runtimeClasspath + files(new File(gradle.ext.openvsxServerDir, 'src/dev/resources')) + mainClass = 'org.eclipse.openvsx.RegistryApplication' + + // Upstream's one-time dev bootstrap: the dev profile includes the gitignored + // application-ovsx.properties, which developers generate in their checkout. + // Generate it on first run so a fresh submodule works out of the box. + doFirst { + def serverDir = gradle.ext.openvsxServerDir as File + if (!new File(serverDir, 'src/dev/resources/application-ovsx.properties').exists()) { + def generate = new ProcessBuilder('bash', 'scripts/generate-properties.sh') + .directory(serverDir) + .inheritIO() + .start() + if (generate.waitFor() != 0) { + throw new GradleException("${serverDir}/scripts/generate-properties.sh failed") + } + } + } +} + +springBoot { + // The application class is upstream's; this module only layers deployment-specific + // beans on top via auto-configuration. Do not add a second @SpringBootApplication. + mainClass = 'org.eclipse.openvsx.RegistryApplication' +} + +bootJar { + // Keep the artifact name the Docker image entrypoint expects. + archiveFileName = 'openvsx-server.jar' + + // bootJar hoists this module's META-INF resources to the jar root, but the + // image entrypoint launches with -cp BOOT-INF/classes:BOOT-INF/lib/* (no jar + // root on the classpath), which would silently drop the auto-configuration + // registration. Keep a copy under BOOT-INF/classes. + from(sourceSets.main.resources) { + include 'META-INF/spring/**' + into 'BOOT-INF/classes' + } +} diff --git a/server/gradle/wrapper/gradle-wrapper.jar b/server/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..b1b8ef56b44f16b14dc800fa8103a6d89abb526f GIT binary patch literal 48462 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 R2np~f1o-$>Q}y+){|A@R9n$~+ literal 0 HcmV?d00001 diff --git a/server/gradle/wrapper/gradle-wrapper.properties b/server/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 000000000..df6a6ad76 --- /dev/null +++ b/server/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,9 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip +networkTimeout=10000 +retries=0 +retryBackOffMs=500 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/server/gradlew b/server/gradlew new file mode 100755 index 000000000..b9bb139f7 --- /dev/null +++ b/server/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# 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 +# +# https://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. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/server/gradlew.bat b/server/gradlew.bat new file mode 100644 index 000000000..aa5f10b06 --- /dev/null +++ b/server/gradlew.bat @@ -0,0 +1,82 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:execute +@rem Setup the command line + + + +@rem Execute Gradle +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel + +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/server/settings.gradle b/server/settings.gradle new file mode 100644 index 000000000..38f269894 --- /dev/null +++ b/server/settings.gradle @@ -0,0 +1,36 @@ +pluginManagement { + repositories { + gradlePluginPortal() + mavenCentral() + } + resolutionStrategy { + eachPlugin { + // Keep the Spring Boot plugin in lockstep with the upstream server build. + // The version is derived below from the server's version catalog. + if (requested.id.id == 'org.springframework.boot') { + useVersion(gradle.ext.springBootVersion) + } + } + } +} + +rootProject.name = 'open-vsx-org' + +// The upstream server, consumed as a library through a Gradle composite build: +// the 'upstream' submodule by default, or any checkout via -PopenvsxServerPath. +def serverDir = file(providers.gradleProperty('openvsxServerPath').getOrElse('upstream/server')) +if (!new File(serverDir, 'gradle/libs.versions.toml').exists()) { + throw new GradleException("No openvsx server build at ${serverDir}. Run" + + " 'git submodule update --init' or pass -PopenvsxServerPath=/server.") +} + +// Also used by the runServer task (dev configuration). +gradle.ext.openvsxServerDir = serverDir + +// The Spring Boot and Java versions are derived from the upstream version catalog, +// never declared here. +def tomlText = new File(serverDir, 'gradle/libs.versions.toml').text +gradle.ext.springBootVersion = (tomlText =~ /(?m)^spring-boot\s*=\s*"([^"]+)"/).collect { it[1] }.first() +gradle.ext.javaVersion = (tomlText =~ /(?m)^java\s*=\s*"([^"]+)"/).collect { it[1] }.first() + +includeBuild(serverDir) diff --git a/server/upstream b/server/upstream new file mode 160000 index 000000000..a596db555 --- /dev/null +++ b/server/upstream @@ -0,0 +1 @@ +Subproject commit a596db555c47efa9c63347d444142d064c1f813e From 2c8d170647d7daa586c8bfcfd50bafb1470e2a15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jordi=20G=C3=B3mez?= Date: Thu, 30 Jul 2026 10:07:48 +0200 Subject: [PATCH 2/2] feat: bring the Eclipse publisher agreement into this deployment The org.eclipse.openvsx.eclipse code extracted upstream lands here as org.eclipsefdn.openvsx.eclipse, wired exclusively through EclipseFoundationAutoConfiguration against the upstream seams (PublisherAgreementService, OAuth2LoginHandler). Same endpoints, same ovsx.eclipse.* configuration keys; a dedicated Swagger UI group documents the relocated endpoint. Integration tests boot the merged application against Testcontainers PostgreSQL, and a negative test proves the registry stays healthy with the auto-configuration excluded. server/NOTES.md carries the full PoC write-up. --- server/NOTES.md | 268 +++++++++ server/build.gradle | 23 + .../EclipseFoundationAutoConfiguration.java | 98 ++++ .../openvsx/eclipse/EclipseLoginHandler.java | 108 ++++ .../openvsx/eclipse/EclipseProfile.java | 186 ++++++ .../openvsx/eclipse/EclipseService.java | 545 ++++++++++++++++++ .../openvsx/eclipse/EclipseTokenService.java | 158 +++++ .../openvsx/eclipse/PublisherAgreement.java | 21 + .../eclipse/PublisherAgreementAPI.java | 61 ++ .../eclipse/PublisherAgreementResponse.java | 54 ++ .../eclipse/PublisherComplianceChecker.java | 121 ++++ .../openvsx/eclipse/SignAgreementParam.java | 54 ++ ...ot.autoconfigure.AutoConfiguration.imports | 1 + .../EclipseFoundationIntegrationTest.java | 86 +++ .../openvsx/eclipse/EclipseServiceTest.java | 503 ++++++++++++++++ .../WithoutEclipseAutoConfigurationTest.java | 66 +++ .../AbstractRegistryIntegrationTest.java | 37 ++ .../support/MockTransactionTemplate.java | 32 + server/src/test/resources/application.yml | 50 ++ .../eclipse/profile-allowed-response.json | 43 ++ .../eclipse/profile-outdated-response.json | 43 ++ .../openvsx/eclipse/profile-response.json | 43 ++ ...publisher-agreement-outdated-response.json | 12 + .../eclipse/publisher-agreement-response.json | 12 + server/upstream | 2 +- 25 files changed, 2626 insertions(+), 1 deletion(-) create mode 100644 server/NOTES.md create mode 100644 server/src/main/java/org/eclipsefdn/openvsx/eclipse/EclipseFoundationAutoConfiguration.java create mode 100644 server/src/main/java/org/eclipsefdn/openvsx/eclipse/EclipseLoginHandler.java create mode 100644 server/src/main/java/org/eclipsefdn/openvsx/eclipse/EclipseProfile.java create mode 100644 server/src/main/java/org/eclipsefdn/openvsx/eclipse/EclipseService.java create mode 100644 server/src/main/java/org/eclipsefdn/openvsx/eclipse/EclipseTokenService.java create mode 100644 server/src/main/java/org/eclipsefdn/openvsx/eclipse/PublisherAgreement.java create mode 100644 server/src/main/java/org/eclipsefdn/openvsx/eclipse/PublisherAgreementAPI.java create mode 100644 server/src/main/java/org/eclipsefdn/openvsx/eclipse/PublisherAgreementResponse.java create mode 100644 server/src/main/java/org/eclipsefdn/openvsx/eclipse/PublisherComplianceChecker.java create mode 100644 server/src/main/java/org/eclipsefdn/openvsx/eclipse/SignAgreementParam.java create mode 100644 server/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports create mode 100644 server/src/test/java/org/eclipsefdn/openvsx/eclipse/EclipseFoundationIntegrationTest.java create mode 100644 server/src/test/java/org/eclipsefdn/openvsx/eclipse/EclipseServiceTest.java create mode 100644 server/src/test/java/org/eclipsefdn/openvsx/eclipse/WithoutEclipseAutoConfigurationTest.java create mode 100644 server/src/test/java/org/eclipsefdn/openvsx/eclipse/support/AbstractRegistryIntegrationTest.java create mode 100644 server/src/test/java/org/eclipsefdn/openvsx/eclipse/support/MockTransactionTemplate.java create mode 100644 server/src/test/resources/application.yml create mode 100644 server/src/test/resources/org/eclipsefdn/openvsx/eclipse/profile-allowed-response.json create mode 100644 server/src/test/resources/org/eclipsefdn/openvsx/eclipse/profile-outdated-response.json create mode 100644 server/src/test/resources/org/eclipsefdn/openvsx/eclipse/profile-response.json create mode 100644 server/src/test/resources/org/eclipsefdn/openvsx/eclipse/publisher-agreement-outdated-response.json create mode 100644 server/src/test/resources/org/eclipsefdn/openvsx/eclipse/publisher-agreement-response.json diff --git a/server/NOTES.md b/server/NOTES.md new file mode 100644 index 000000000..f7661b6f6 --- /dev/null +++ b/server/NOTES.md @@ -0,0 +1,268 @@ +# PoC: open-vsx.org as a Spring Boot app on top of the OSS registry + +This branch demonstrates that `EclipseFdn/open-vsx.org` can run as its own Spring +Boot application that consumes `eclipse-openvsx/openvsx` (the `server` project) as a +library, contributing deployment-specific code via Spring Boot auto-configuration. +The functionality used to prove it: the Eclipse publisher agreement, extracted from +upstream's `org.eclipse.openvsx.eclipse` into this repository. + +Paired branches: + +- upstream: `poc/eclipse-extraction` (gnugomez/openvsx, fork of eclipse-openvsx/openvsx) +- instance: `poc/openvsx-eclipse-module` (this repository; gnugomez/open-vsx.org) + +## How to build and run locally + +All Gradle machinery lives under `server/` (mirroring the upstream repo layout); +the repo root stays website + deployment config. The upstream server is consumed +as source through a Gradle composite build over the `server/upstream` git +submodule, pinned to the paired upstream branch. Any other checkout can be used +instead with `-PopenvsxServerPath=/server`. + +```bash +# fresh clone, no other checkouts needed +git clone --recurse-submodules -b poc/openvsx-eclipse-module +cd open-vsx.org/server +./gradlew test # incl. booting the merged app on Testcontainers PostgreSQL +./gradlew bootJar # the deployable jar + +# dev server on the host JVM, like upstream's `./gradlew runServer` +# (first run generates upstream's gitignored dev profile automatically) +docker compose -f upstream/docker-compose.yml up -d postgres +./gradlew runServer # http://localhost:8080 + +# Docker image (from the repo root): by default the server-src stage clones +# SERVER_REPO at SERVER_VERSION (the pinned fork branch), so this works with no +# local upstream at all +docker build -t openvsx-website:poc . + +# or build offline from the submodule / any local checkout +docker build --build-context server-src=server/upstream -t openvsx-website:poc . +``` + +In production the composite build would be replaced by a published +`org.eclipse.openvsx:openvsx-server` artifact — see the productionizing section. + +### Baseline (recorded before any change) + +- upstream `main` (36de5ace): `./gradlew build` — BUILD SUCCESSFUL, 798 tests, 2m33s + (Testcontainers; Docker required) +- instance `aws-main` (90036b9): `cd website && yarn install --immutable && yarn build` + — built in ~5s + +## Packaging design + +- `server/` is a single-project Spring Boot build. Its `bootJar` (named + `openvsx-server.jar` like upstream's) has the upstream server and all its + dependencies in `BOOT-INF/lib` and (eventually) only the deployment-specific + classes in `BOOT-INF/classes`. +- Main class is upstream's `org.eclipse.openvsx.RegistryApplication` — the module + deliberately has no `@SpringBootApplication` of its own. +- The Spring Boot plugin version and the Java version are parsed out of the upstream + checkout's `gradle/libs.versions.toml` in `settings.gradle`; this build declares + neither independently. +- No `application.yml` is packaged in the module jar. Configuration keeps flowing + through the image's `config/application.yml` (copied from `configuration/`, version + placeholder sed-replaced) plus `spring.config.import: file:${DEPLOYMENT_CONFIG}`, + exactly as today. +- The final Docker image replicates the upstream-derived image byte for byte in + layout: exploded boot jar in `/home/openvsx/server`, upstream's `run-server.sh` + as entrypoint (`java -cp BOOT-INF/classes:BOOT-INF/lib/* ...`), website dist at + `BOOT-INF/classes/static/`, logback config and mail templates in + `BOOT-INF/classes/`. Helm charts, ESO secrets and environment variables are + untouched. The base image is a plain JRE (`eclipse-temurin:25-jre`) instead of + `ghcr.io/eclipse-openvsx/openvsx-server-snapshot`. + +### Packaging parity evidence + +`BOOT-INF/lib` of the instance bootJar is identical to upstream's bootJar except for +`openvsx-server-plain.jar` itself (upstream ships those classes as `BOOT-INF/classes` +instead). `BOOT-INF/classes` of the module jar is empty at the packaging-only +milestone. + +## Upstream changes (the "consumable library" enablement) + +1. **Publish the `java` component** (`from components.java`): the plain jar + (classifier `plain`, already produced by the Boot plugin) plus real dependency + metadata in the POM/Gradle module metadata. The executable `bootJar` stays the + main artifact, so nothing changes for existing consumers of the publication. +2. **Expose the effective dependency versions to consumers.** The + `io.spring.dependency-management` plugin (managed BOM versions, declared-version + pins) applies only inside the upstream project; a consumer resolving the library + would find versionless dependencies it cannot resolve at all. The fix (applied + with `java-library` so the constraints reach both the api and runtime variants): + mirror the effective managed versions — Spring Boot BOM + upstream's property + overrides, with explicitly declared versions winning like they do upstream — as + plain dependency constraints on `api`. + The constraints are deliberately **not strict**. A first attempt with + `strictly` pins blew up: a strict constraint does not downgrade a *sibling* + dependency edge that requires a higher version — it fails resolution — and + Jackson 3's Gradle module metadata (fetched lazily; failures appeared only after + the metadata landed in the cache, which made them look nondeterministic) requires + e.g. `woodstox-core 7.1.1` while upstream pins 6.4.0. Where Maven-like + "managed version wins" resolution differs from Gradle's highest-version-wins, + parity is enforced on the *instance* side with a short, documented + `resolutionStrategy.force` list (gson, woodstox-core, the two CVE range floors, + and test-only byte-buddy/mockito) in `server/build.gradle`, verified by + diffing `BOOT-INF/lib` against an upstream bootJar. + The tomcat→jetty module replacement (`modules { replacedBy ... }`) cannot be + exported at all — Gradle component metadata rules are project-local — so the + instance module repeats those 3 lines. Without it, Tomcat lands on the classpath + alongside Jetty and Spring Boot silently auto-configures Tomcat. + +## Seams introduced upstream + +Every point where core upstream code called into `org.eclipse.openvsx.eclipse` was +converted to one of two small, generically named interfaces (or the code moved out +entirely). What a third-party deployment could do with them is noted per seam. + +### 1. `org.eclipse.openvsx.publish.PublisherAgreementService` (interface, all methods default no-op) + +Consumed via `@Nullable PublisherAgreementService` constructor injection with a +no-op anonymous default (`publisherAgreement != null ? publisherAgreement : new +PublisherAgreementService() {}`), so a vanilla registry runs without extra +configuration. `@Nullable` (the repo's existing pattern for optional beans, see +`SimilarityCheckService`) was chosen over `Optional<>` because Mockito's +`@InjectMocks` cannot supply `Optional` constructor parameters — `Optional<>` broke +`AdminServiceTest`. The instance auto-configuration contributes `EclipseService` as +the implementation. Call sites: + +| method | caller | purpose | +|---|---|---| +| `checkPublisherAgreement(user)` | `LocalRegistryService.createNamespace` / `.publish` | the publishing gate | +| `enrichUserJsonWithPublisherAgreement(json, user)` | `UserAPI.getUserData` (`GET /user`) | agreement status in the profile response | +| `adminEnrichUserJson(json, user)` | `AdminService.getUserPublishInfo` | agreement status in the admin view | +| `revokePublisherAgreement(user, admin)` | `AdminService.revokePublisherContributions` | external revocation on admin action | + +The `isActive() && eclipsePersonId != null` guard that used to sit in `AdminService` +moved *inside* the implementation — the interface contract is "called +unconditionally, implementation decides". A third-party deployment could implement +this to require any kind of publisher vetting (a CLA, a paid plan, a manual allow +list) without touching upstream. + +### 2. `org.eclipse.openvsx.security.OAuth2LoginHandler` (interface) + +`OAuth2UserServices`, `SecurityConfig` and `CustomAuthenticationSuccessHandler` +previously hard-coded the `"eclipse"` registration id in three behaviors. They now +consume a `List` (empty by default) keyed by +`getRegistrationId()`: + +- `loadUser(userRequest)` — replaces the `case "eclipse" -> loadEclipseUser(...)` + switch arm; registrations without a handler use the generic attribute-mapping flow. +- `authenticationSucceeded(principal, accessToken, refreshToken)` — replaces the + event-listener branch that stored the Eclipse token. +- `getSuccessRedirectUrl(defaultTargetUrl)` — replaces the hard-coded post-login + redirect to `/user-settings/profile`. + +The instance contributes `EclipseLoginHandler`, which links the Eclipse account to +the logged-in GitHub user (profile fetch, GitHub-handle cross-check), stores the +token, and redirects to the profile page. A third party could use the same SPI for +any "secondary account linking" provider. The `ECLIPSE_MISSING_GITHUB_ID` / +`ECLIPSE_MISMATCH_GITHUB_ID` error codes moved out of upstream's +`CodedAuthException` into the handler (the wire format is unchanged — they were +plain strings). + +### 3. Moves without a seam + +- `POST /user/publisher-agreement` existed solely for the agreement → the endpoint + moved verbatim to `PublisherAgreementAPI` in this module (same path, same + request/response shapes, same CSRF posture). On top of the move, the module + contributes a "Publisher Agreement" Swagger UI group (`GroupedOpenApi` bean in the + auto-configuration) documenting the endpoint — upstream's groups only cover + `/api/**`, `/vscode/**` and `/admin/**`, so `/user/**` endpoints were never in the + Swagger UI. The extra dropdown entry doubles as a visible marker that the registry + is running with the Eclipse extension; it disappears with the auto-configuration + (covered by the negative test). +- `PublisherComplianceChecker` (the `ovsx.eclipse.check-compliance-on-start` startup + check) only depends on public upstream services → moved wholesale. +- `EclipseService`, `EclipseTokenService` and the DTOs + (`EclipseProfile`, `PublisherAgreement`, `PublisherAgreementResponse`, + `SignAgreementParam`) moved to `org.eclipsefdn.openvsx.eclipse` unchanged apart + from the package statement, the `PublisherAgreementService` implementation + declaration and the relocated revocation guard. All `ovsx.eclipse.*` configuration + keys are unchanged. + +### Accepted residue upstream (documented, not moved) + +- `UserData.eclipsePersonId` / `UserData.eclipseToken` — database columns; the PoC + brief forbids schema changes. Productionizing the extraction fully would need a + generic "linked account / auth token" storage or instance-owned persistence. +- `UserJson.PublisherAgreement` — part of the public API response shape consumed by + the web UI; treated as a generic "publisher agreement" concept in the API model. + The seam interface reuses it, so it arguably belongs upstream anyway. + +## Verification results + +- **Upstream suite** (`./gradlew build`, Testcontainers): green before the change + (798 tests) and green after the extraction (784 tests — the missing 14 are + `EclipseServiceTest`, relocated here and passing). +- **Instance build** (`./gradlew test` in `server/`): all green — + the 14 relocated `EclipseServiceTest` cases, a `@SpringBootTest` booting the + merged application against Testcontainers PostgreSQL (upstream endpoints respond, + `POST /user/publisher-agreement` is mapped, `PublisherAgreementService` resolves + to `EclipseService`, the `eclipse` login handler and compliance checker are + registered), and the negative test (auto-configuration excluded via + `spring.autoconfigure.exclude` → application healthy, agreement bean and endpoint + absent). +- **Dependency parity**: `BOOT-INF/lib` of the module's bootJar is identical to an + upstream bootJar's, except `openvsx-server-plain.jar` itself (whose classes are + upstream's `BOOT-INF/classes`). +- **Docker image**: boots on a plain JRE base with a production-shaped + `DEPLOYMENT_CONFIG`; Jetty (not Tomcat) serves; website, `/user`, + `/login-providers`, `/api/version` (sed-substituted version string) and database + search respond as before; the agreement endpoint is mapped. +- **Helm/ESO**: `git diff aws-main..HEAD -- charts kubernetes dashboards + configuration mail-templates Jenkinsfile` is empty. + +## Gotchas encountered + +- Upstream's `bootJar`/`jar` names carry no version (`version` is unset), so the + library publishes as `org.eclipse.openvsx:openvsx-server:unspecified`. Composite + builds don't care (substitution ignores versions), but real artifact publication + needs a version scheme. +- The upstream main jar ships **no** `application.yml` (only `src/dev` and + `src/test` do), so the "no application.yml in the instance jar" rule is naturally + satisfied; the deployment already gets its base config from `config/application.yml` + in the image. +- Named Docker build contexts (`--build-context server-src=…`) replace the + `server-src` stage wholesale; the stage is normalized (`FROM scratch` + + `COPY --from=server-clone`) so both the clone default and the local override + present the same layout to later stages. +- Gradle rich-version gotcha (cost the most time of anything here): a `strictly` + constraint does not downgrade a sibling dependency edge that requires a higher + version — resolution fails. And because Jackson 3 ships Gradle module metadata + that is only fetched when first needed, the failures appeared a build *after* the + change that triggered the fetch. Hence plain constraints + instance-side forces. +- Boot 4 modularization details surface in a consumer that upstream never sees: + `TestRestTemplate` lives in `spring-boot-resttestclient` (via + `spring-boot-starter-webmvc-test`), Testcontainers 2.x uses + `org.testcontainers:testcontainers-postgresql` (not 1.x `:postgresql`), and + Mockito cannot `@InjectMocks` an `Optional<>` constructor parameter (hence the + `@Nullable` seam injection upstream). +- The instance module compiles against Spring/Jakarta/etc. directly, so it declares + those dependencies itself (versionless, resolved via the server's published + constraints) instead of leaning on upstream's `implementation` classpath leaking + through. +- **The sneakiest one:** `bootJar` hoists the application's own `META-INF/**` + resources to the *jar root*, not `BOOT-INF/classes` — and `run-server.sh` launches + with `java -cp BOOT-INF/classes:BOOT-INF/lib/*`, which never sees the exploded + jar root. The auto-configuration registration + (`META-INF/spring/….AutoConfiguration.imports`) silently vanished from the + runtime classpath: the container booted healthy but *without* the publisher + agreement, while every Gradle-run test (which uses the plain resources dir) + passed. Caught only by smoke-testing the real image. Fixed by copying + `META-INF/spring/**` into `BOOT-INF/classes` in the `bootJar` task; the + moved-endpoint probe is part of the container smoke test now. + +## Productionizing (honest assessment) + +- Publish `org.eclipse.openvsx:openvsx-server` (plain jar + POM + module metadata) + to a real repository (Maven Central or GitHub Packages) with a version scheme; + the composite build is a stopgap that compiles upstream from source on every image + build. +- CI: the instance build needs a pinned upstream ref (build arg `SERVER_VERSION`) + and a cache for Gradle dependencies; the current Dockerfile downloads everything + per build, like upstream's own Dockerfile. +- Upgrade workflow: bumping upstream = bumping one ref/version and re-running the + instance test suite; API-breaking upstream changes surface as compile errors in + the instance build instead of image-assembly surprises. diff --git a/server/build.gradle b/server/build.gradle index 049e386ea..bb0a547ef 100644 --- a/server/build.gradle +++ b/server/build.gradle @@ -18,6 +18,16 @@ dependencies { // build (settings.gradle) substitutes the included server project. implementation 'org.eclipse.openvsx:openvsx-server' + // What this module's own sources compile against. No versions: the server + // library publishes its effective managed versions as dependency constraints. + implementation "org.springframework.boot:spring-boot-starter-webmvc" + implementation "org.springframework.boot:spring-boot-starter-data-jpa" + implementation "org.springframework.security:spring-security-oauth2-client" + implementation "org.apache.commons:commons-lang3" + implementation "com.fasterxml.jackson.core:jackson-annotations" + implementation "tools.jackson.core:jackson-databind" + implementation "org.springdoc:springdoc-openapi-starter-webmvc-ui" + // Mirrors the server's tomcat -> jetty replacement; component module rules are // project-local upstream and do not propagate to consumers. modules { @@ -25,6 +35,14 @@ dependencies { replacedBy("org.springframework.boot:spring-boot-starter-jetty") } } + + testImplementation "org.springframework.boot:spring-boot-starter-test" + testImplementation "org.springframework.boot:spring-boot-starter-webmvc-test" + testImplementation "org.springframework.boot:spring-boot-testcontainers" + testImplementation "org.testcontainers:testcontainers-junit-jupiter" + testImplementation "org.testcontainers:testcontainers-postgresql" + testImplementation "io.micrometer:micrometer-core" + testImplementation "org.jobrunr:jobrunr-spring-boot-4-starter" } // The server's io.spring.dependency-management plugin resolves its graph with @@ -47,6 +65,11 @@ configurations.configureEach { } } +test { + jvmArgs = ['--enable-native-access=ALL-UNNAMED', '-Xmx2048m'] + useJUnitPlatform() +} + // Same developer entry point as upstream's `./gradlew runServer`: runs the registry // on the host JVM with upstream's dev configuration (src/dev/resources), plus this // module's beans. Expects the dev services from upstream's docker-compose.yml diff --git a/server/src/main/java/org/eclipsefdn/openvsx/eclipse/EclipseFoundationAutoConfiguration.java b/server/src/main/java/org/eclipsefdn/openvsx/eclipse/EclipseFoundationAutoConfiguration.java new file mode 100644 index 000000000..36e537d8e --- /dev/null +++ b/server/src/main/java/org/eclipsefdn/openvsx/eclipse/EclipseFoundationAutoConfiguration.java @@ -0,0 +1,98 @@ +/******************************************************************************** + * Copyright (c) 2026 Eclipse Foundation and others + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipsefdn.openvsx.eclipse; + +import jakarta.persistence.EntityManager; +import org.springdoc.core.customizers.OpenApiCustomizer; +import org.springdoc.core.models.GroupedOpenApi; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; +import org.springframework.transaction.support.TransactionTemplate; +import org.springframework.web.client.RestTemplate; + +import org.eclipse.openvsx.ExtensionService; +import org.eclipse.openvsx.UserService; +import org.eclipse.openvsx.repositories.RepositoryService; + +/** + * Registers the Eclipse Foundation publisher agreement integration on top of the + * upstream registry. The package is outside upstream's component scan, so every + * bean is declared explicitly here and the class is registered in + * {@code META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports}. + */ +@AutoConfiguration +public class EclipseFoundationAutoConfiguration { + + @Bean + public EclipseTokenService eclipseTokenService( + TransactionTemplate transactions, + EntityManager entityManager, + ObjectProvider clientRegistrationRepository + ) { + return new EclipseTokenService(transactions, entityManager, clientRegistrationRepository.getIfAvailable()); + } + + @Bean + public EclipseService eclipseService( + EclipseTokenService tokens, + ExtensionService extensions, + EntityManager entityManager, + @Qualifier("restTemplate") RestTemplate restTemplate + ) { + return new EclipseService(tokens, extensions, entityManager, restTemplate); + } + + @Bean + public EclipseLoginHandler eclipseLoginHandler( + EclipseService eclipse, + EclipseTokenService tokens, + EntityManager entityManager + ) { + return new EclipseLoginHandler(eclipse, tokens, entityManager); + } + + @Bean + public PublisherAgreementAPI publisherAgreementAPI(UserService users, EclipseService eclipse) { + return new PublisherAgreementAPI(users, eclipse); + } + + /** + * Extra Swagger UI group for the endpoint this deployment contributes; upstream's + * groups (see its DocumentationConfig) are untouched. Also serves as a visible + * marker that the registry is running with the Eclipse extension. + */ + @Bean + public GroupedOpenApi publisherAgreementOpenApi(OpenApiCustomizer sortSchemasAlphabetically) { + var description = "Eclipse Foundation publisher agreement management," + + " contributed by the open-vsx.org deployment on top of the open-source registry."; + return GroupedOpenApi.builder() + .group("publisher-agreement") + .displayName("Publisher Agreement") + .pathsToMatch("/user/publisher-agreement") + .addOpenApiCustomizer( + openApi -> openApi.getInfo().title("Eclipse Publisher Agreement API").description(description)) + .addOpenApiCustomizer(sortSchemasAlphabetically) + .build(); + } + + @Bean + public PublisherComplianceChecker publisherComplianceChecker( + TransactionTemplate transactions, + EntityManager entityManager, + RepositoryService repositories, + ExtensionService extensions, + EclipseService eclipseService + ) { + return new PublisherComplianceChecker(transactions, entityManager, repositories, extensions, eclipseService); + } +} diff --git a/server/src/main/java/org/eclipsefdn/openvsx/eclipse/EclipseLoginHandler.java b/server/src/main/java/org/eclipsefdn/openvsx/eclipse/EclipseLoginHandler.java new file mode 100644 index 000000000..58ccda330 --- /dev/null +++ b/server/src/main/java/org/eclipsefdn/openvsx/eclipse/EclipseLoginHandler.java @@ -0,0 +1,108 @@ +/******************************************************************************** + * Copyright (c) 2026 Eclipse Foundation and others + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipsefdn.openvsx.eclipse; + +import jakarta.persistence.EntityManager; +import org.apache.commons.lang3.StringUtils; +import org.springframework.security.authentication.AuthenticationServiceException; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.core.OAuth2AccessToken; +import org.springframework.security.oauth2.core.OAuth2RefreshToken; + +import org.eclipse.openvsx.entities.UserData; +import org.eclipse.openvsx.security.CodedAuthException; +import org.eclipse.openvsx.security.IdPrincipal; +import org.eclipse.openvsx.security.OAuth2LoginHandler; +import org.eclipse.openvsx.util.ErrorResultException; +import org.eclipse.openvsx.util.UrlUtil; + +import static org.eclipse.openvsx.security.CodedAuthException.NEED_MAIN_LOGIN; + +/** + * Handles the 'eclipse' OAuth2 registration: it links an Eclipse Foundation + * account to the already logged-in user instead of creating a new account, and + * stores the Eclipse access token for publisher agreement API requests. + */ +public class EclipseLoginHandler implements OAuth2LoginHandler { + + public static final String ECLIPSE_MISSING_GITHUB_ID = "eclipse-missing-github-id"; + public static final String ECLIPSE_MISMATCH_GITHUB_ID = "eclipse-mismatch-github-id"; + + private final EclipseService eclipse; + private final EclipseTokenService tokens; + private final EntityManager entityManager; + + public EclipseLoginHandler(EclipseService eclipse, EclipseTokenService tokens, EntityManager entityManager) { + this.eclipse = eclipse; + this.tokens = tokens; + this.entityManager = entityManager; + } + + @Override + public String getRegistrationId() { + return "eclipse"; + } + + @Override + public IdPrincipal loadUser(OAuth2UserRequest userRequest) { + var authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication == null) { + throw new CodedAuthException( + "Please log in with GitHub before connecting your Eclipse account.", + NEED_MAIN_LOGIN); + } + if (!(authentication.getPrincipal() instanceof IdPrincipal)) { + throw new CodedAuthException("The current authentication is invalid.", NEED_MAIN_LOGIN); + } + var principal = (IdPrincipal) authentication.getPrincipal(); + var userData = entityManager.find(UserData.class, principal.getId()); + if (userData == null) { + throw new CodedAuthException("The current authentication has no backing data.", NEED_MAIN_LOGIN); + } + try { + var accessToken = userRequest.getAccessToken().getTokenValue(); + var profile = eclipse.getUserProfile(accessToken); + if (StringUtils.isEmpty(profile.getGithubHandle())) { + throw new CodedAuthException( + "Your Eclipse profile is missing a GitHub username.", + ECLIPSE_MISSING_GITHUB_ID); + } + if (!profile.getGithubHandle().equalsIgnoreCase(userData.getLoginName())) { + throw new CodedAuthException( + "The GitHub username setting in your Eclipse profile (" + + profile.getGithubHandle() + + ") does not match your GitHub authentication (" + + userData.getLoginName() + ").", + ECLIPSE_MISMATCH_GITHUB_ID); + } + + eclipse.updateUserData(userData, profile); + return principal; + } catch (ErrorResultException exc) { + throw new AuthenticationServiceException(exc.getMessage(), exc); + } + } + + @Override + public void authenticationSucceeded( + IdPrincipal principal, + OAuth2AccessToken accessToken, + OAuth2RefreshToken refreshToken + ) { + tokens.updateEclipseToken(principal.getId(), accessToken, refreshToken); + } + + @Override + public String getSuccessRedirectUrl(String defaultTargetUrl) { + // Redirect to user profile page after login to Eclipse + return UrlUtil.createApiUrl(defaultTargetUrl, "user-settings", "profile"); + } +} diff --git a/server/src/main/java/org/eclipsefdn/openvsx/eclipse/EclipseProfile.java b/server/src/main/java/org/eclipsefdn/openvsx/eclipse/EclipseProfile.java new file mode 100644 index 000000000..a6623d19d --- /dev/null +++ b/server/src/main/java/org/eclipsefdn/openvsx/eclipse/EclipseProfile.java @@ -0,0 +1,186 @@ +/******************************************************************************** + * Copyright (c) 2020 TypeFox and others + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipsefdn.openvsx.eclipse; + +import java.util.List; +import java.util.Optional; + +import com.fasterxml.jackson.annotation.JsonProperty; +import tools.jackson.core.JacksonException; +import tools.jackson.core.JsonParser; +import tools.jackson.core.JsonToken; +import tools.jackson.core.type.TypeReference; +import tools.jackson.databind.DeserializationContext; +import tools.jackson.databind.ValueDeserializer; +import tools.jackson.databind.annotation.JsonDeserialize; + +public class EclipseProfile { + + private String uid; + + private String name; + + private String mail; + + private String picture; + + @JsonProperty("first_name") + private String firstName; + + @JsonProperty("last_name") + private String lastName; + + @JsonProperty("full_name") + private String fullName; + + @JsonProperty("github_handle") + private String githubHandle; + + @JsonProperty("twitter_handle") + private String twitterHandle; + + @JsonProperty("publisher_agreements") + @JsonDeserialize(using = PublisherAgreements.Deserializer.class) + private PublisherAgreements publisherAgreements; + + public String getUid() { + return uid; + } + + public void setUid(String uid) { + this.uid = uid; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getMail() { + return mail; + } + + public void setMail(String mail) { + this.mail = mail; + } + + public String getPicture() { + return picture; + } + + public void setPicture(String picture) { + this.picture = picture; + } + + public String getFirstName() { + return firstName; + } + + public void setFirstName(String firstName) { + this.firstName = firstName; + } + + public String getLastName() { + return lastName; + } + + public void setLastName(String lastName) { + this.lastName = lastName; + } + + public String getFullName() { + return fullName; + } + + public void setFullName(String fullName) { + this.fullName = fullName; + } + + public String getGithubHandle() { + return githubHandle; + } + + public void setGithubHandle(String githubHandle) { + this.githubHandle = githubHandle; + } + + public String getTwitterHandle() { + return twitterHandle; + } + + public void setTwitterHandle(String twitterHandle) { + this.twitterHandle = twitterHandle; + } + + public PublisherAgreements getPublisherAgreements() { + return publisherAgreements; + } + + public void setPublisherAgreements(PublisherAgreements publisherAgreements) { + this.publisherAgreements = publisherAgreements; + } + + public Optional getOpenVsxPublisherAgreement() { + if (publisherAgreements != null && publisherAgreements.getOpenVsx() != null) { + return Optional.of(publisherAgreements.getOpenVsx()); + } else { + return Optional.empty(); + } + } + + public static class PublisherAgreements { + + @JsonProperty("open-vsx") + private PublisherAgreement openVsx; + + public PublisherAgreement getOpenVsx() { + return openVsx; + } + + public void setOpenVsx(PublisherAgreement openVsx) { + this.openVsx = openVsx; + } + + public static class Deserializer extends ValueDeserializer { + + private static final TypeReference> TYPE_LIST_AGREEMENT = new TypeReference<>() { + }; + + @Override + public PublisherAgreements deserialize(JsonParser p, DeserializationContext ctxt) throws JacksonException { + if (p.currentToken() == JsonToken.START_ARRAY) { + var list = ctxt.readValue(p, TYPE_LIST_AGREEMENT); + var result = new PublisherAgreements(); + if (!list.isEmpty()) { + result.openVsx = list.getFirst(); + } + return result; + } + return ctxt.readValue(p, PublisherAgreements.class); + } + + } + } + + public static class PublisherAgreement { + private String version; + + public String getVersion() { + return version; + } + + public void setVersion(String version) { + this.version = version; + } + } +} diff --git a/server/src/main/java/org/eclipsefdn/openvsx/eclipse/EclipseService.java b/server/src/main/java/org/eclipsefdn/openvsx/eclipse/EclipseService.java new file mode 100644 index 000000000..47e31c5fe --- /dev/null +++ b/server/src/main/java/org/eclipsefdn/openvsx/eclipse/EclipseService.java @@ -0,0 +1,545 @@ +/******************************************************************************** + * Copyright (c) 2020 TypeFox and others + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipsefdn.openvsx.eclipse; + +import java.net.URI; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeFormatterBuilder; +import java.time.format.DateTimeParseException; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.regex.Pattern; + +import jakarta.persistence.EntityManager; +import jakarta.transaction.Transactional; +import org.apache.commons.lang3.StringUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.*; +import org.springframework.web.client.HttpStatusCodeException; +import org.springframework.web.client.RestClientException; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.util.UriComponentsBuilder; +import tools.jackson.core.JacksonException; +import tools.jackson.core.type.TypeReference; +import tools.jackson.databind.json.JsonMapper; + +import org.eclipse.openvsx.ExtensionService; +import org.eclipse.openvsx.entities.AuthToken; +import org.eclipse.openvsx.entities.UserData; +import org.eclipse.openvsx.json.UserJson; +import org.eclipse.openvsx.publish.PublisherAgreementService; +import org.eclipse.openvsx.util.ErrorResultException; +import org.eclipse.openvsx.util.HttpHeadersUtil; +import org.eclipse.openvsx.util.TimeUtil; + +public class EclipseService implements PublisherAgreementService { + + private static final String VAR_PERSON_ID = "personId"; + + public static final DateTimeFormatter CUSTOM_DATE_TIME = new DateTimeFormatterBuilder() + .parseCaseInsensitive() + .append(DateTimeFormatter.ISO_LOCAL_DATE) + .appendLiteral(' ') + .append(DateTimeFormatter.ISO_LOCAL_TIME) + .toFormatter(); + + private static final TypeReference> TYPE_LIST_STRING = new TypeReference<>() { + }; + private static final TypeReference> TYPE_LIST_PROFILE = new TypeReference<>() { + }; + private static final TypeReference> TYPE_LIST_AGREEMENT = new TypeReference<>() { + }; + + protected final Logger logger = LoggerFactory.getLogger(EclipseService.class); + + private final EclipseTokenService tokens; + private final ExtensionService extensions; + private final EntityManager entityManager; + private final RestTemplate restTemplate; + private final JsonMapper jsonMapper; + + @Value("${ovsx.eclipse.base-url:}") + String eclipseApiUrl; + + @Value("${ovsx.eclipse.publisher-agreement.version:}") + String publisherAgreementVersion; + + @Value("${ovsx.eclipse.publisher-agreement.allowed-versions:}") + List publisherAgreementAllowedVersions; + + public EclipseService( + EclipseTokenService tokens, + ExtensionService extensions, + EntityManager entityManager, + RestTemplate restTemplate + ) { + this.tokens = tokens; + this.extensions = extensions; + this.entityManager = entityManager; + this.restTemplate = restTemplate; + this.jsonMapper = JsonMapper.builder().build(); + } + + public boolean isActive() { + return !StringUtils.isEmpty(publisherAgreementVersion) && !publisherAgreementAllowedVersions.isEmpty(); + } + + /** + * Check whether the given user has an active publisher agreement. + * @throws ErrorResultException if the user has no active agreement + */ + @Override + public void checkPublisherAgreement(UserData user) { + if (!isActive()) { + return; + } + // Users without authentication provider have been created directly in the DB, + // so we skip the agreement check in this case. + if (user.getProvider() == null) { + return; + } + var personId = user.getEclipsePersonId(); + if (personId == null) { + throw new ErrorResultException( + "You must log in with an Eclipse Foundation account and sign a Publisher Agreement before publishing any extension."); + } + + var json = user.toUserJson(); + enrichUserJsonWithPublisherAgreement(json, user); + var publisherAgreement = json.getPublisherAgreement(); + + if (publisherAgreement == null || publisherAgreement.getStatus().equals("none")) { + throw new ErrorResultException( + "You must sign a Publisher Agreement with the Eclipse Foundation before publishing any extension."); + } + + if (!publisherAgreement.getStatus().equals("signed")) { + if (publisherAgreement.getVersion() != null) { + throw new ErrorResultException( + "Your Publisher Agreement with the Eclipse Foundation is outdated (version " + + publisherAgreement.getVersion() + "). The current version is " + + publisherAgreementVersion + "."); + } else { + throw new ErrorResultException("Your Publisher Agreement with the Eclipse Foundation is outdated."); + } + } + } + + /** + * Get the publicly available user profile. + */ + public EclipseProfile getPublicProfile(String personId) { + var urlTemplate = buildApiUrl("account/profile/{personId}"); + var uriVariables = Map.of(VAR_PERSON_ID, personId); + var request = new HttpEntity(HttpHeadersUtil.getAcceptJsonHeaders()); + + try { + var response = restTemplate.exchange(urlTemplate, HttpMethod.GET, request, String.class, uriVariables); + return parseEclipseProfile(response); + } catch (RestClientException exc) { + if (exc instanceof HttpStatusCodeException) { + var status = ((HttpStatusCodeException) exc).getStatusCode(); + if (status == HttpStatus.NOT_FOUND) { + throw new ErrorResultException( + "No Eclipse profile data available for user '" + personId + "': " + exc.getMessage()); + } + } + + var url = UriComponentsBuilder.fromUriString(urlTemplate).build(uriVariables); + logger.error("Get request failed with URL: {}", url, exc); + throw new ErrorResultException( + "Request for retrieving user profile failed: " + exc.getMessage(), + HttpStatus.INTERNAL_SERVER_ERROR); + } + } + + /** + * Update the given user data with a profile obtained from Eclipse API. + */ + @Transactional + public void updateUserData(UserData user, EclipseProfile profile) { + user = entityManager.merge(user); + user.setEclipsePersonId(profile.getName()); + } + + @Override + public void enrichUserJsonWithPublisherAgreement(UserJson json, UserData user) { + var usableToken = true; + PublisherAgreement agreement = null; + try { + // Add information on the publisher agreement + agreement = getPublisherAgreement(user); + } catch (ErrorResultException e) { + if (e.getStatus() == HttpStatus.FORBIDDEN) { + usableToken = false; + } else { + logger.warn("Failed to retrieve publisher agreement", e); + } + } + + // If we do not have a valid access token, access the public profile to find a signed OpenVSX publisher agreement. + // Note: this service uses cached data so it might not reflect the actual situation. + if (!usableToken) { + var eclipsePersonId = user.getEclipsePersonId(); + if (eclipsePersonId != null) { + try { + var profile = getPublicProfile(user.getEclipsePersonId()); + var publisherAgreement = profile.getOpenVsxPublisherAgreement(); + if (publisherAgreement.isPresent()) { + agreement = new PublisherAgreement(true, null, publisherAgreement.get().getVersion(), null); + } + } catch (ErrorResultException e) { + // public profile could not be retrieved for the user, could be blocked. + logger.warn(e.getMessage()); + } + } + } + + enrichUserJson(json, user, agreement, usableToken); + } + + public void enrichUserJson(UserJson json, UserData user, PublisherAgreement agreement) { + enrichUserJson(json, user, agreement, true); + } + + /** + * Enrich the given JSON user data with Eclipse-specific information. + */ + private void enrichUserJson(UserJson json, UserData user, PublisherAgreement agreement, boolean usableToken) { + if (!isActive()) { + return; + } + + var publisherAgreement = new UserJson.PublisherAgreement(); + publisherAgreement.setStatus("none"); + json.setPublisherAgreement(publisherAgreement); + + var personId = user.getEclipsePersonId(); + if (personId == null) { + return; + } + + if (agreement != null && agreement.isActive() && agreement.version() != null) { + var status = publisherAgreementAllowedVersions.contains(agreement.version()) ? "signed" : "outdated"; + publisherAgreement.setStatus(status); + } + + if (agreement != null) { + publisherAgreement.setVersion(agreement.version()); + } + + if (agreement != null && agreement.timestamp() != null) { + publisherAgreement.setTimestamp(TimeUtil.toUTCString(agreement.timestamp())); + } + + // Report user as logged in only if there is a usable token: + // we need the token to access the Eclipse REST API + if (usableToken) { + var eclipseLogin = new UserJson(); + eclipseLogin.setProvider("eclipse"); + eclipseLogin.setLoginName(personId); + if (json.getAdditionalLogins() == null) { + json.setAdditionalLogins(new ArrayList<>(List.of(eclipseLogin))); + } else { + json.getAdditionalLogins().add(eclipseLogin); + } + } + } + + @Override + public void adminEnrichUserJson(UserJson json, UserData user) { + if (!isActive()) { + return; + } + + var publisherAgreement = new UserJson.PublisherAgreement(); + var personId = user.getEclipsePersonId(); + if (personId == null) { + publisherAgreement.setStatus("none"); + return; + } + + try { + var profile = getPublicProfile(personId); + var openVsxPublisherAgreement = profile.getOpenVsxPublisherAgreement(); + if (openVsxPublisherAgreement.isEmpty() + || StringUtils.isEmpty(openVsxPublisherAgreement.get().getVersion())) { + publisherAgreement.setStatus("none"); + } else if (publisherAgreementAllowedVersions.contains(openVsxPublisherAgreement.get().getVersion())) { + publisherAgreement.setStatus("signed"); + } else { + publisherAgreement.setStatus("outdated"); + } + + json.setPublisherAgreement(publisherAgreement); + } catch (ErrorResultException e) { + logger.error("Failed to get public profile", e); + } + } + + /** + * Get the user profile available through an access token. + */ + public EclipseProfile getUserProfile(String accessToken) { + var requestUrl = buildApiUrl("openvsx/profile"); + var headers = HttpHeadersUtil.getAcceptJsonHeaders(); + headers.setBearerAuth(accessToken); + var request = new RequestEntity<>(headers, HttpMethod.GET, URI.create(requestUrl)); + + try { + var response = restTemplate.exchange(request, String.class); + return parseEclipseProfile(response); + } catch (RestClientException exc) { + logger.error("Get request failed with URL: {}", requestUrl, exc); + throw new ErrorResultException( + "Request for retrieving user profile failed: " + exc.getMessage(), + HttpStatus.INTERNAL_SERVER_ERROR); + } + } + + private EclipseProfile parseEclipseProfile(ResponseEntity response) { + var json = response.getBody(); + if (json == null) { + return new EclipseProfile(); + } + + try { + if (json.startsWith("[\"")) { + var error = jsonMapper.readValue(json, TYPE_LIST_STRING); + logger.error("Profile request failed:\n{}", json); + throw new ErrorResultException( + "Request to the Eclipse Foundation server failed: " + error, + HttpStatus.INTERNAL_SERVER_ERROR); + } else if (json.startsWith("[")) { + var profileList = jsonMapper.readValue(json, TYPE_LIST_PROFILE); + if (profileList.isEmpty()) { + throw new ErrorResultException( + "No Eclipse user profile available.", + HttpStatus.INTERNAL_SERVER_ERROR); + } + return profileList.getFirst(); + } else { + return jsonMapper.readValue(json, EclipseProfile.class); + } + } catch (JacksonException exc) { + logger.error("Failed to parse JSON response ({}):\n{}", response.getStatusCode(), json, exc); + throw new ErrorResultException( + "Parsing Eclipse user profile failed: " + exc.getMessage(), + HttpStatus.INTERNAL_SERVER_ERROR); + } + } + + /** + * Get the publisher agreement of the given user with the user's current access token. + */ + public PublisherAgreement getPublisherAgreement(UserData user) { + var eclipseToken = checkEclipseToken(user); + var personId = user.getEclipsePersonId(); + if (StringUtils.isEmpty(personId)) { + return null; + } + var urlTemplate = buildApiUrl("openvsx/publisher_agreement/{personId}"); + var uriVariables = Map.of(VAR_PERSON_ID, personId); + var headers = HttpHeadersUtil.getAcceptJsonHeaders(); + headers.setBearerAuth(eclipseToken.accessToken()); + var request = new HttpEntity<>(headers); + + try { + var json = restTemplate.exchange(urlTemplate, HttpMethod.GET, request, String.class, uriVariables); + return parseAgreementResponse(json); + } catch (RestClientException exc) { + HttpStatusCode status = HttpStatus.INTERNAL_SERVER_ERROR; + if (exc instanceof HttpStatusCodeException) { + status = ((HttpStatusCodeException) exc).getStatusCode(); + // The endpoint yields 404 if the specified user has not signed a publisher agreement + if (status == HttpStatus.NOT_FOUND) { + return null; + } + } + + var url = UriComponentsBuilder.fromUriString(urlTemplate).build(uriVariables); + logger.error("Get request failed with URL: {}", url, exc); + throw new ErrorResultException( + "Request for retrieving publisher agreement failed: " + exc.getMessage(), + status); + } + } + + private static final Pattern STATUS_400_MESSAGE = Pattern + .compile("400 Bad Request: \\[\\[\"(?[^\"]+)\"]]"); + + /** + * Sign the publisher agreement on behalf of the given user. + */ + public PublisherAgreement signPublisherAgreement(UserData user) { + var requestUrl = buildApiUrl("openvsx/publisher_agreement"); + var eclipseToken = checkEclipseToken(user); + var headers = HttpHeadersUtil.getAcceptJsonHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.setBearerAuth(eclipseToken.accessToken()); + var data = new SignAgreementParam(publisherAgreementVersion, user.getLoginName()); + var request = new HttpEntity<>(data, headers); + + try { + var json = restTemplate.postForEntity(requestUrl, request, String.class); + + // The request was successful: reactivate all previously published extensions + extensions.reactivateExtensions(user); + + // Parse the response and store the publisher agreement metadata + return parseAgreementResponse(json); + } catch (RestClientException exc) { + String message = exc.getMessage(); + var statusCode = HttpStatus.INTERNAL_SERVER_ERROR; + if (exc instanceof HttpStatusCodeException) { + var excStatus = ((HttpStatusCodeException) exc).getStatusCode(); + // The endpoint yields 409 if the specified user has already signed a publisher agreement + if (excStatus == HttpStatus.CONFLICT) { + message = "A publisher agreement is already present for user " + user.getLoginName() + "."; + statusCode = HttpStatus.BAD_REQUEST; + } else if (excStatus == HttpStatus.BAD_REQUEST) { + var matcher = STATUS_400_MESSAGE.matcher(exc.getMessage()); + if (matcher.matches()) { + message = matcher.group("message"); + } + } + } + if (statusCode == HttpStatus.INTERNAL_SERVER_ERROR) { + message = "Request for signing publisher agreement failed: " + message; + } + + String payload; + try { + payload = jsonMapper.writeValueAsString(data); + } catch (JacksonException exc2) { + payload = "<" + exc2.getMessage() + ">"; + } + logger.error("Post request failed with URL: {} Payload: {}", requestUrl, payload, exc); + throw new ErrorResultException(message, statusCode); + } + } + + private PublisherAgreement parseAgreementResponse(ResponseEntity response) { + var json = response.getBody(); + if (json == null) { + return null; + } + + try { + PublisherAgreementResponse agreementResponse; + if (json.startsWith("[\"")) { + var error = jsonMapper.readValue(json, TYPE_LIST_STRING); + logger.error("Publisher agreement request failed:\n{}", json); + throw new ErrorResultException( + "Request to the Eclipse Foundation server failed: " + error, + HttpStatus.INTERNAL_SERVER_ERROR); + } else if (json.startsWith("[")) { + var profileList = jsonMapper.readValue(json, TYPE_LIST_AGREEMENT); + if (profileList.isEmpty()) { + throw new ErrorResultException( + "No publisher agreement available.", + HttpStatus.INTERNAL_SERVER_ERROR); + } + agreementResponse = profileList.getFirst(); + } else { + agreementResponse = jsonMapper.readValue(json, PublisherAgreementResponse.class); + } + + var timestamp = parseDate(agreementResponse.effectiveDate); + return new PublisherAgreement( + TimeUtil.getCurrentUTC().isAfter(timestamp), + agreementResponse.documentID, + agreementResponse.version, + timestamp); + } catch (JacksonException exc) { + logger.error("Failed to parse JSON response ({}):\n{}", response.getStatusCode(), json, exc); + throw new ErrorResultException( + "Parsing publisher agreement response failed: " + exc.getMessage(), + HttpStatus.INTERNAL_SERVER_ERROR); + } + } + + private LocalDateTime parseDate(String dateString) { + try { + return LocalDateTime.parse(dateString, CUSTOM_DATE_TIME); + } catch (DateTimeParseException exc) { + logger.error("Failed to parse timestamp.", exc); + return null; + } + } + + /** + * Revoke the given user's publisher agreement. If an admin user is given, + * the admin's access token is used for the Eclipse API request, otherwise + * the access token of the target user is used. + */ + @Override + public void revokePublisherAgreement(UserData user, UserData admin) { + if (!isActive() || user.getEclipsePersonId() == null) { + return; + } + checkEclipseData(user); + + var eclipseToken = admin == null ? checkEclipseToken(user) : checkEclipseToken(admin); + var headers = new HttpHeaders(); + headers.setBearerAuth(eclipseToken.accessToken()); + var request = new HttpEntity<>(headers); + var urlTemplate = buildApiUrl("openvsx/publisher_agreement/{personId}"); + var uriVariables = Map.of(VAR_PERSON_ID, user.getEclipsePersonId()); + + try { + var requestCallback = restTemplate.httpEntityCallback(request); + restTemplate.execute(urlTemplate, HttpMethod.DELETE, requestCallback, null, uriVariables); + } catch (RestClientException exc) { + var url = UriComponentsBuilder.fromUriString(urlTemplate).build(uriVariables); + logger.error("Delete request failed with URL: {}", url, exc); + throw new ErrorResultException( + "Request for revoking publisher agreement failed: " + exc.getMessage(), + HttpStatus.INTERNAL_SERVER_ERROR); + } + } + + private void checkApiUrl() { + if (StringUtils.isEmpty(eclipseApiUrl)) { + throw new ErrorResultException("Missing URL for Eclipse API."); + } + } + + private String buildApiUrl(String path) { + checkApiUrl(); + + var baseUrl = eclipseApiUrl; + if (eclipseApiUrl.charAt(eclipseApiUrl.length() - 1) != '/') { + baseUrl += '/'; + } + + return baseUrl + path; + } + + private AuthToken checkEclipseToken(UserData user) { + var eclipseToken = tokens.getActiveEclipseToken(user); + if (eclipseToken == null || StringUtils.isEmpty(eclipseToken.accessToken())) { + throw new ErrorResultException("Authorization by Eclipse required.", HttpStatus.FORBIDDEN); + } + return eclipseToken; + } + + private void checkEclipseData(UserData user) { + if (StringUtils.isEmpty(user.getEclipsePersonId())) { + throw new ErrorResultException( + "Eclipse person ID is unavailable for user: " + + user.getProvider() + "/" + user.getLoginName()); + } + } +} diff --git a/server/src/main/java/org/eclipsefdn/openvsx/eclipse/EclipseTokenService.java b/server/src/main/java/org/eclipsefdn/openvsx/eclipse/EclipseTokenService.java new file mode 100644 index 000000000..dab14be6b --- /dev/null +++ b/server/src/main/java/org/eclipsefdn/openvsx/eclipse/EclipseTokenService.java @@ -0,0 +1,158 @@ +/******************************************************************************** + * Copyright (c) 2020 TypeFox and others + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipsefdn.openvsx.eclipse; + +import java.time.Instant; +import java.util.List; +import java.util.Optional; + +import jakarta.persistence.EntityManager; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.util.Pair; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; +import org.springframework.security.oauth2.core.OAuth2AccessToken; +import org.springframework.security.oauth2.core.OAuth2AccessToken.TokenType; +import org.springframework.security.oauth2.core.OAuth2RefreshToken; +import org.springframework.transaction.support.TransactionTemplate; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.RestClientException; +import org.springframework.web.client.RestTemplate; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.json.JsonMapper; + +import org.eclipse.openvsx.entities.AuthToken; +import org.eclipse.openvsx.entities.UserData; + +public class EclipseTokenService { + + protected final Logger logger = LoggerFactory.getLogger(EclipseTokenService.class); + + private final TransactionTemplate transactions; + private final EntityManager entityManager; + private final ClientRegistrationRepository clientRegistrationRepository; + private final JsonMapper jsonMapper; + + public EclipseTokenService( + TransactionTemplate transactions, + EntityManager entityManager, + @Autowired(required = false) ClientRegistrationRepository clientRegistrationRepository + ) { + this.transactions = transactions; + this.entityManager = entityManager; + this.clientRegistrationRepository = clientRegistrationRepository; + this.jsonMapper = JsonMapper.builder().build(); + } + + public AuthToken updateEclipseToken(long userId, OAuth2AccessToken accessToken, OAuth2RefreshToken refreshToken) { + var token = toAuthToken(accessToken, refreshToken); + return transactions.execute(status -> { + var userData = entityManager.find(UserData.class, userId); + userData.setEclipseToken(token); + return token; + }); + } + + private AuthToken toAuthToken(OAuth2AccessToken accessToken, OAuth2RefreshToken refreshToken) { + if (accessToken == null) { + return null; + } + + String refresh = null; + Instant refreshExpiresAt = null; + if (refreshToken != null) { + refresh = refreshToken.getTokenValue(); + refreshExpiresAt = refreshToken.getExpiresAt(); + } + + return new AuthToken( + accessToken.getTokenValue(), + accessToken.getIssuedAt(), + accessToken.getExpiresAt(), + accessToken.getScopes(), + refresh, + refreshExpiresAt); + } + + public AuthToken getActiveEclipseToken(UserData userData) { + var token = userData.getEclipseToken(); + if (token != null && isExpired(token.expiresAt())) { + OAuth2AccessToken newAccessToken = null; + OAuth2RefreshToken newRefreshToken = null; + var newTokens = refreshEclipseToken(token); + if (newTokens != null) { + newAccessToken = newTokens.getFirst(); + newRefreshToken = newTokens.getSecond(); + } + + return updateEclipseToken(userData.getId(), newAccessToken, newRefreshToken); + } + return token; + } + + private boolean isExpired(Instant instant) { + return instant != null && Instant.now().isAfter(instant); + } + + private Pair refreshEclipseToken(AuthToken token) { + if (token.refreshToken() == null || isExpired(token.refreshExpiresAt())) { + return null; + } + + var reg = Optional.ofNullable(clientRegistrationRepository).map(repo -> repo.findByRegistrationId("eclipse")) + .orElse(null); + if (reg == null) { + logger.error("Eclipse client not registered"); + return null; + } + + var tokenUri = reg.getProviderDetails().getTokenUri(); + + var headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + headers.setAccept(List.of(MediaType.APPLICATION_JSON)); + + var data = new LinkedMultiValueMap<>(); + data.add("grant_type", "refresh_token"); + data.add("client_id", reg.getClientId()); + data.add("client_secret", reg.getClientSecret()); + data.add("refresh_token", token.refreshToken()); + + try { + var request = new HttpEntity<>(data, headers); + var restTemplate = new RestTemplate(); + var response = restTemplate.postForObject(tokenUri, request, String.class); + var root = jsonMapper.readTree(response); + var newTokenValue = root.get("access_token").asString(); + var newRefreshTokenValue = root.get("refresh_token").asString(); + var expires_in = root.get("expires_in").asLong(); + + var issuedAt = Instant.now(); + var expiresAt = issuedAt.plusSeconds(expires_in); + + var newToken = new OAuth2AccessToken(TokenType.BEARER, newTokenValue, issuedAt, expiresAt); + var newRefreshToken = new OAuth2RefreshToken(newRefreshTokenValue, issuedAt); + return Pair.of(newToken, newRefreshToken); + } catch (HttpClientErrorException.BadRequest exc) { + // keycloak sends a 400 status response if the refresh call failed + logger.warn("Eclipse token could not be refreshed: {}", exc.getMessage()); + } catch (RestClientException exc) { + logger.error("Post request failed with URL: {}", tokenUri, exc); + } catch (JacksonException exc) { + logger.error("Invalid JSON data received from URL: {}", tokenUri, exc); + } + return null; + } +} diff --git a/server/src/main/java/org/eclipsefdn/openvsx/eclipse/PublisherAgreement.java b/server/src/main/java/org/eclipsefdn/openvsx/eclipse/PublisherAgreement.java new file mode 100644 index 000000000..cbb493ce3 --- /dev/null +++ b/server/src/main/java/org/eclipsefdn/openvsx/eclipse/PublisherAgreement.java @@ -0,0 +1,21 @@ +/******************************************************************************** + * Copyright (c) 2020 TypeFox and others + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipsefdn.openvsx.eclipse; + +import java.time.LocalDateTime; + +/** + * + * @param isActive + * @param documentId + * @param version Version of the last signed publisher agreement. + * @param timestamp Timestamp of the last signed publisher agreement. + */ +public record PublisherAgreement(boolean isActive, String documentId, String version, LocalDateTime timestamp) {} diff --git a/server/src/main/java/org/eclipsefdn/openvsx/eclipse/PublisherAgreementAPI.java b/server/src/main/java/org/eclipsefdn/openvsx/eclipse/PublisherAgreementAPI.java new file mode 100644 index 000000000..4827413a7 --- /dev/null +++ b/server/src/main/java/org/eclipsefdn/openvsx/eclipse/PublisherAgreementAPI.java @@ -0,0 +1,61 @@ +/******************************************************************************** + * Copyright (c) 2026 Eclipse Foundation and others + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipsefdn.openvsx.eclipse; + +import io.swagger.v3.oas.annotations.Operation; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RestController; + +import org.eclipse.openvsx.UserService; +import org.eclipse.openvsx.json.UserJson; +import org.eclipse.openvsx.util.ErrorResultException; +import org.eclipse.openvsx.util.UrlUtil; + +import static org.eclipse.openvsx.util.UrlUtil.createApiUrl; + +@RestController +public class PublisherAgreementAPI { + + private final UserService users; + private final EclipseService eclipse; + + public PublisherAgreementAPI(UserService users, EclipseService eclipse) { + this.users = users; + this.eclipse = eclipse; + } + + @Operation(summary = "Sign the Eclipse Foundation publisher agreement on behalf of the logged-in user") + @PostMapping( + path = "/user/publisher-agreement", + produces = MediaType.APPLICATION_JSON_VALUE + ) + public ResponseEntity signPublisherAgreement() { + var user = users.findLoggedInUser(); + if (user == null) { + return new ResponseEntity<>(HttpStatus.FORBIDDEN); + } + try { + var agreement = eclipse.signPublisherAgreement(user); + var json = user.toUserJson(); + var serverUrl = UrlUtil.getBaseUrl(); + json.setRole(user.getRoleAsString()); + json.setTokensUrl(createApiUrl(serverUrl, "user", "tokens")); + json.setCreateTokenUrl(createApiUrl(serverUrl, "user", "token", "create")); + eclipse.enrichUserJson(json, user, agreement); + + return ResponseEntity.ok(json); + } catch (ErrorResultException exc) { + return exc.toResponseEntity(UserJson.class); + } + } +} diff --git a/server/src/main/java/org/eclipsefdn/openvsx/eclipse/PublisherAgreementResponse.java b/server/src/main/java/org/eclipsefdn/openvsx/eclipse/PublisherAgreementResponse.java new file mode 100644 index 000000000..1425d097f --- /dev/null +++ b/server/src/main/java/org/eclipsefdn/openvsx/eclipse/PublisherAgreementResponse.java @@ -0,0 +1,54 @@ +/******************************************************************************** + * Copyright (c) 2020 TypeFox and others + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipsefdn.openvsx.eclipse; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * https://eclipsefdn.github.io/openvsx-publisher-agreement-specs/#/paths/~1publisher_agreement/post + */ +class PublisherAgreementResponse { + + /** Unique identifier for an addressable object in the API. */ + @JsonProperty("PersonID") + String personID; + + /** Unique identifier for an addressable object in the API. */ + @JsonProperty("DocumentID") + String documentID; + + /** The version number for the current document. */ + @JsonProperty("Version") + String version; + + /** Date string in the RFC 3339 format. */ + @JsonProperty("EffectiveDate") + String effectiveDate; + + /** Date string in the RFC 3339 format. */ + @JsonProperty("ReceivedDate") + String receivedDate; + + /** The signed document as a blob entity. */ + @JsonProperty("ScannedDocumentBLOB") + String scannedDocumentBLOB; + + /** The MIME type for the posted document blob. */ + @JsonProperty("ScannedDocumentMime") + String scannedDocumentMime; + + /** The name of the document being posted. */ + @JsonProperty("ScannedDocumentFileName") + String scannedDocumentFileName; + + /** Comment about the document being posted. */ + @JsonProperty("Comments") + String comments; +} diff --git a/server/src/main/java/org/eclipsefdn/openvsx/eclipse/PublisherComplianceChecker.java b/server/src/main/java/org/eclipsefdn/openvsx/eclipse/PublisherComplianceChecker.java new file mode 100644 index 000000000..e519aff97 --- /dev/null +++ b/server/src/main/java/org/eclipsefdn/openvsx/eclipse/PublisherComplianceChecker.java @@ -0,0 +1,121 @@ +/******************************************************************************** + * Copyright (c) 2020 TypeFox and others + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipsefdn.openvsx.eclipse; + +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +import jakarta.persistence.EntityManager; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.context.event.ApplicationStartedEvent; +import org.springframework.context.event.EventListener; +import org.springframework.transaction.support.TransactionTemplate; + +import org.eclipse.openvsx.ExtensionService; +import org.eclipse.openvsx.entities.Extension; +import org.eclipse.openvsx.entities.PersonalAccessToken; +import org.eclipse.openvsx.entities.UserData; +import org.eclipse.openvsx.repositories.RepositoryService; +import org.eclipse.openvsx.util.NamingUtil; + +public class PublisherComplianceChecker { + + protected final Logger logger = LoggerFactory.getLogger(PublisherComplianceChecker.class); + + private final TransactionTemplate transactions; + private final EntityManager entityManager; + private final RepositoryService repositories; + private final ExtensionService extensions; + private final EclipseService eclipseService; + + @Value("${ovsx.eclipse.check-compliance-on-start:false}") + boolean checkCompliance; + + public PublisherComplianceChecker( + TransactionTemplate transactions, + EntityManager entityManager, + RepositoryService repositories, + ExtensionService extensions, + EclipseService eclipseService + ) { + this.transactions = transactions; + this.entityManager = entityManager; + this.repositories = repositories; + this.extensions = extensions; + this.eclipseService = eclipseService; + } + + @EventListener + public void checkPublishers(ApplicationStartedEvent event) { + if (!checkCompliance || !eclipseService.isActive()) { + return; + } + + var publisherTokens = repositories.findAllAccessTokens().stream() + .collect(Collectors.groupingBy(PersonalAccessToken::getUser)); + publisherTokens.keySet().forEach(user -> { + var accessTokens = publisherTokens.get(user); + if (!accessTokens.isEmpty() && !isCompliant(user)) { + // Found a non-compliant publisher: deactivate all extension versions + transactions.execute(status -> { + deactivateExtensions(accessTokens); + return null; + }); + } + }); + } + + private boolean isCompliant(UserData user) { + // Users without authentication provider have been created directly in the DB, + // so we skip the agreement check in this case. + if (user.getProvider() == null) { + return true; + } + if (user.getEclipsePersonId() == null) { + // The user has never logged in with Eclipse + return false; + } + + var profile = eclipseService.getPublicProfile(user.getEclipsePersonId()); + return Optional.of(profile) + .map(EclipseProfile::getPublisherAgreements) + .map(EclipseProfile.PublisherAgreements::getOpenVsx) + .map(EclipseProfile.PublisherAgreement::getVersion) + .isPresent(); + } + + private void deactivateExtensions(List accessTokens) { + var affectedExtensions = new LinkedHashSet(); + for (var accessToken : accessTokens) { + var versions = repositories.findVersionsByAccessToken(accessToken, true); + for (var version : versions) { + version.setActive(false); + entityManager.merge(version); + var extension = version.getExtension(); + affectedExtensions.add(extension); + logger.atInfo() + .setMessage("Deactivated: {} - {}") + .addArgument(() -> accessToken.getUser().getLoginName()) + .addArgument(() -> NamingUtil.toLogFormat(version)) + .log(); + } + } + + // Update affected extensions + for (var extension : affectedExtensions) { + extensions.updateExtension(extension); + entityManager.merge(extension); + } + } +} diff --git a/server/src/main/java/org/eclipsefdn/openvsx/eclipse/SignAgreementParam.java b/server/src/main/java/org/eclipsefdn/openvsx/eclipse/SignAgreementParam.java new file mode 100644 index 000000000..c46a597f1 --- /dev/null +++ b/server/src/main/java/org/eclipsefdn/openvsx/eclipse/SignAgreementParam.java @@ -0,0 +1,54 @@ +/******************************************************************************** + * Copyright (c) 2020 TypeFox and others + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipsefdn.openvsx.eclipse; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * https://eclipsefdn.github.io/openvsx-publisher-agreement-specs/#/paths/~1publisher_agreement/post + */ +public class SignAgreementParam { + + /** + * The version number of the document/agreement. + */ + private String version; + + /** + * The GitHub username of the user. This must match what the Eclipse Foundation has on file + * for the user to successfully sign the publisher agreement. + */ + @JsonProperty("github_handle") + private String githubHandle; + + public SignAgreementParam() { + } + + public SignAgreementParam(String version, String githubHandle) { + this.version = version; + this.githubHandle = githubHandle; + } + + public String getVersion() { + return version; + } + + public void setVersion(String version) { + this.version = version; + } + + public String getGithubHandle() { + return githubHandle; + } + + public void setGithubHandle(String githubHandle) { + this.githubHandle = githubHandle; + } +} diff --git a/server/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/server/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 000000000..13f80e2a7 --- /dev/null +++ b/server/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1 @@ +org.eclipsefdn.openvsx.eclipse.EclipseFoundationAutoConfiguration diff --git a/server/src/test/java/org/eclipsefdn/openvsx/eclipse/EclipseFoundationIntegrationTest.java b/server/src/test/java/org/eclipsefdn/openvsx/eclipse/EclipseFoundationIntegrationTest.java new file mode 100644 index 000000000..594ef2433 --- /dev/null +++ b/server/src/test/java/org/eclipsefdn/openvsx/eclipse/EclipseFoundationIntegrationTest.java @@ -0,0 +1,86 @@ +/******************************************************************************** + * Copyright (c) 2026 Eclipse Foundation and others + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipsefdn.openvsx.eclipse; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.resttestclient.TestRestTemplate; +import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureTestRestTemplate; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.context.ApplicationContext; +import org.springframework.http.HttpStatus; +import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping; + +import org.eclipse.openvsx.RegistryApplication; +import org.eclipse.openvsx.publish.PublisherAgreementService; +import org.eclipse.openvsx.security.OAuth2LoginHandler; +import org.eclipsefdn.openvsx.eclipse.support.AbstractRegistryIntegrationTest; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Boots upstream's RegistryApplication with this module's auto-configuration on the + * classpath and verifies that the publisher agreement integration is wired in. + */ +@SpringBootTest(classes = RegistryApplication.class, webEnvironment = WebEnvironment.RANDOM_PORT) +@AutoConfigureTestRestTemplate +class EclipseFoundationIntegrationTest extends AbstractRegistryIntegrationTest { + + @LocalServerPort + int port; + + @Autowired + TestRestTemplate restTemplate; + + @Autowired + ApplicationContext context; + + @Test + void upstreamEndpointsRespond() { + var response = restTemplate.getForEntity("http://localhost:" + port + "/user", String.class); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).contains("Not logged in."); + } + + @Test + void publisherAgreementEndpointIsMapped() { + assertThat(publisherAgreementMappings(context)).isPositive(); + } + + @Test + void publisherAgreementSwaggerGroupIsPublished() { + var swaggerConfig = restTemplate + .getForEntity("http://localhost:" + port + "/v3/api-docs/swagger-config", String.class); + assertThat(swaggerConfig.getBody()).contains("/v3/api-docs/publisher-agreement"); + + var groupDocs = restTemplate + .getForEntity("http://localhost:" + port + "/v3/api-docs/publisher-agreement", String.class); + assertThat(groupDocs.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(groupDocs.getBody()).contains("\"/user/publisher-agreement\""); + } + + @Test + void eclipseBeansAreRegistered() { + assertThat(context.getBean(PublisherAgreementService.class)).isInstanceOf(EclipseService.class); + assertThat(context.getBean(OAuth2LoginHandler.class)).isInstanceOf(EclipseLoginHandler.class); + assertThat(context.getBean(OAuth2LoginHandler.class).getRegistrationId()).isEqualTo("eclipse"); + assertThat(context.getBean(PublisherComplianceChecker.class)).isNotNull(); + } + + static long publisherAgreementMappings(ApplicationContext context) { + var mappings = context.getBean("requestMappingHandlerMapping", RequestMappingHandlerMapping.class); + return mappings.getHandlerMethods().keySet().stream() + .filter(info -> info.getPathPatternsCondition() != null + && info.getPathPatternsCondition().getPatternValues().contains("/user/publisher-agreement")) + .count(); + } +} diff --git a/server/src/test/java/org/eclipsefdn/openvsx/eclipse/EclipseServiceTest.java b/server/src/test/java/org/eclipsefdn/openvsx/eclipse/EclipseServiceTest.java new file mode 100644 index 000000000..4d46f0617 --- /dev/null +++ b/server/src/test/java/org/eclipsefdn/openvsx/eclipse/EclipseServiceTest.java @@ -0,0 +1,503 @@ +/******************************************************************************** + * Copyright (c) 2020 TypeFox and others + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipsefdn.openvsx.eclipse; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; + +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import jakarta.persistence.EntityManager; +import org.jobrunr.scheduling.JobRequestScheduler; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mockito; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.data.util.Streamable; +import org.springframework.http.*; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.springframework.transaction.support.TransactionTemplate; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.RestTemplate; + +import org.eclipse.openvsx.ExtensionService; +import org.eclipse.openvsx.ExtensionValidator; +import org.eclipsefdn.openvsx.eclipse.support.MockTransactionTemplate; +import org.eclipse.openvsx.UserService; +import org.eclipse.openvsx.adapter.VSCodeIdService; +import org.eclipse.openvsx.cache.CacheService; +import org.eclipse.openvsx.cache.LatestExtensionVersionCacheKeyGenerator; +import org.eclipse.openvsx.entities.*; +import org.eclipse.openvsx.metrics.ExtensionDownloadMetrics; +import org.eclipse.openvsx.publish.PublishExtensionVersionHandler; +import org.eclipse.openvsx.publish.PublishingConfig; +import org.eclipse.openvsx.repositories.RepositoryService; +import org.eclipse.openvsx.scanning.ExtensionScanPersistenceService; +import org.eclipse.openvsx.scanning.ExtensionScanService; +import org.eclipse.openvsx.search.SearchUtilService; +import org.eclipse.openvsx.storage.*; +import org.eclipse.openvsx.storage.log.DownloadCountService; +import org.eclipse.openvsx.util.ErrorResultException; +import org.eclipse.openvsx.util.LogService; +import org.eclipse.openvsx.util.TargetPlatform; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.fail; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; + +@ExtendWith(SpringExtension.class) +@MockitoBean( + types = { + EntityManager.class, + SearchUtilService.class, + GoogleCloudStorageService.class, + AzureBlobStorageService.class, + AwsStorageService.class, + VSCodeIdService.class, + DownloadCountService.class, + ExtensionDownloadMetrics.class, + CacheService.class, + UserService.class, + PublishExtensionVersionHandler.class, + SimpleMeterRegistry.class, + FileCacheDurationConfig.class, + JobRequestScheduler.class, + CdnServiceConfig.class, + ExtensionScanService.class, + ExtensionScanPersistenceService.class, + LogService.class + } +) +class EclipseServiceTest { + + private static final String PUBLIC_PROFILE_URL = "https://test.openvsx.eclipse.org/account/profile/{personId}"; + private static final String PUBLISHER_AGREEMENT_URL = "https://test.openvsx.eclipse.org/openvsx/publisher_agreement/{personId}"; + + @MockitoBean + RepositoryService repositories; + + @MockitoBean + EclipseTokenService tokens; + + @MockitoBean + RestTemplate restTemplate; + + @Autowired + EclipseService eclipse; + + @BeforeEach + void setup() { + eclipse.publisherAgreementAllowedVersions = List.of("1", "1.0", "1.1"); + eclipse.publisherAgreementVersion = "1.1"; + eclipse.eclipseApiUrl = "https://test.openvsx.eclipse.org/"; + } + + @Test + void testGetPublicProfile() throws Exception { + Mockito.when( + restTemplate.exchange( + eq(PUBLIC_PROFILE_URL), + eq(HttpMethod.GET), + any(HttpEntity.class), + eq(String.class), + eq(Map.of("personId", "test")))) + .thenReturn(mockProfileResponse()); + + var profile = eclipse.getPublicProfile("test"); + + assertThat(profile).isNotNull(); + assertThat(profile.getName()).isEqualTo("test"); + assertThat(profile.getGithubHandle()).isEqualTo("test"); + assertThat(profile.getPublisherAgreements()).isNotNull(); + assertThat(profile.getPublisherAgreements().getOpenVsx()).isNotNull(); + assertThat(profile.getPublisherAgreements().getOpenVsx().getVersion()).isEqualTo("1.1"); + } + + @Test + void testGetUserProfile() throws Exception { + Mockito.when(restTemplate.exchange(any(RequestEntity.class), eq(String.class))) + .thenReturn(mockProfileResponse()); + + var profile = eclipse.getUserProfile("12345"); + + assertThat(profile).isNotNull(); + + assertThat(profile.getName()).isEqualTo("test"); + assertThat(profile.getGithubHandle()).isEqualTo("test"); + assertThat(profile.getPublisherAgreements()).isNotNull(); + assertThat(profile.getPublisherAgreements().getOpenVsx()).isNotNull(); + assertThat(profile.getPublisherAgreements().getOpenVsx().getVersion()).isEqualTo("1.1"); + } + + @Test + void testGetPublisherAgreement() throws Exception { + var user = mockUser(); + user.setEclipsePersonId("test"); + + Mockito.when( + restTemplate.exchange( + eq(PUBLISHER_AGREEMENT_URL), + eq(HttpMethod.GET), + any(HttpEntity.class), + eq(String.class), + eq(Map.of("personId", "test")))) + .thenReturn(mockAgreementResponse()); + + var agreement = eclipse.getPublisherAgreement(user); + assertThat(agreement).isNotNull(); + assertThat(agreement.isActive()).isTrue(); + assertThat(agreement.documentId()).isEqualTo("abcd"); + assertThat(agreement.version()).isEqualTo("1.1"); + assertThat(agreement.timestamp()).isEqualTo(LocalDateTime.of(2020, 10, 9, 5, 10, 32)); + } + + @Test + void testCheckPublisherOutdatedAgreement() throws Exception { + var user = mockUser(); + user.setEclipsePersonId("test"); + + Mockito.when( + restTemplate.exchange( + eq(PUBLISHER_AGREEMENT_URL), + eq(HttpMethod.GET), + any(HttpEntity.class), + eq(String.class), + eq(Map.of("personId", "test")))) + .thenReturn(mockOutdatedAgreementResponse()); + + try { + eclipse.checkPublisherAgreement(user); + fail("Expected an ErrorResultException"); + } catch (ErrorResultException exc) { + assertThat(exc.getMessage()).isEqualTo( + "Your Publisher Agreement with the Eclipse Foundation is outdated (version 0.1). The current version is 1.1."); + } + } + + @Test + void testCheckPublisherOutdatedAgreementNoToken() throws Exception { + var user = mockUserNoToken(); + user.setEclipsePersonId("test"); + + Mockito.when( + restTemplate.exchange( + eq(PUBLIC_PROFILE_URL), + eq(HttpMethod.GET), + any(HttpEntity.class), + eq(String.class), + eq(Map.of("personId", "test")))) + .thenReturn(mockOutdatedProfileResponse()); + + try { + eclipse.checkPublisherAgreement(user); + fail("Expected an ErrorResultException"); + } catch (ErrorResultException exc) { + assertThat(exc.getMessage()).isEqualTo( + "Your Publisher Agreement with the Eclipse Foundation is outdated (version 0.1). The current version is 1.1."); + } + } + + @Test + void testCheckPublisherAgreementAllowed() throws Exception { + var user = mockUser(); + user.setEclipsePersonId("test"); + + Mockito.when( + restTemplate.exchange( + eq(PUBLISHER_AGREEMENT_URL), + eq(HttpMethod.GET), + any(HttpEntity.class), + eq(String.class), + eq(Map.of("personId", "test")))) + .thenReturn(mockAgreementResponse()); + + eclipse.checkPublisherAgreement(user); + } + + @Test + void testCheckPublisherAgreementAllowedNoToken() throws Exception { + var user = mockUserNoToken(); + user.setEclipsePersonId("test"); + + Mockito.when( + restTemplate.exchange( + eq(PUBLIC_PROFILE_URL), + eq(HttpMethod.GET), + any(HttpEntity.class), + eq(String.class), + eq(Map.of("personId", "test")))) + .thenReturn(mockAllowedProfileResponse()); + + eclipse.checkPublisherAgreement(user); + } + + @Test + void testGetPublisherAgreementNotFound() throws Exception { + var user = mockUser(); + user.setEclipsePersonId("test"); + + var urlTemplate = "https://test.openvsx.eclipse.org/openvsx/publisher_agreement/{personId}"; + Mockito.when( + restTemplate.exchange( + eq(urlTemplate), + eq(HttpMethod.GET), + any(HttpEntity.class), + eq(String.class), + eq(Map.of("personId", "test")))) + .thenThrow(new HttpClientErrorException(HttpStatus.NOT_FOUND)); + + var agreement = eclipse.getPublisherAgreement(user); + assertThat(agreement).isNull(); + } + + @Test + void testGetPublisherAgreementNotAuthenticated() throws Exception { + var user = mockUser(); + + var agreement = eclipse.getPublisherAgreement(user); + + assertThat(agreement).isNull(); + } + + @Test + void testSignPublisherAgreement() throws Exception { + var user = mockUser(); + Mockito.when(restTemplate.postForEntity(any(String.class), any(), eq(String.class))) + .thenReturn(mockAgreementResponse()); + Mockito.when(repositories.findVersionsByUser(user, false)) + .thenReturn(Streamable.empty()); + + var agreement = eclipse.signPublisherAgreement(user); + assertThat(agreement).isNotNull(); + assertThat(agreement.isActive()).isTrue(); + assertThat(agreement.documentId()).isEqualTo("abcd"); + assertThat(agreement.version()).isEqualTo("1.1"); + assertThat(agreement.timestamp()).isEqualTo(LocalDateTime.of(2020, 10, 9, 5, 10, 32)); + } + + @Test + void testSignPublisherAgreementReactivateExtension() throws Exception { + var user = mockUser(); + Mockito.when(restTemplate.postForEntity(any(String.class), any(), eq(String.class))) + .thenReturn(mockAgreementResponse()); + var namespace = new Namespace(); + namespace.setName("foo"); + var extension = new Extension(); + extension.setName("bar"); + extension.setNamespace(namespace); + var extVersion = new ExtensionVersion(); + extVersion.setVersion("1.0.0"); + extVersion.setTargetPlatform(TargetPlatform.NAME_UNIVERSAL); + extVersion.setExtension(extension); + extension.getVersions().add(extVersion); + Mockito.when(repositories.findVersionsByUser(user, false)) + .thenReturn(Streamable.of(extVersion)); + + var agreement = eclipse.signPublisherAgreement(user); + + assertThat(agreement).isNotNull(); + assertThat(agreement.isActive()).isTrue(); + assertThat(agreement.documentId()).isEqualTo("abcd"); + assertThat(agreement.version()).isEqualTo("1.1"); + assertThat(agreement.timestamp()).isEqualTo(LocalDateTime.of(2020, 10, 9, 5, 10, 32)); + assertThat(extVersion.isActive()).isTrue(); + assertThat(extension.isActive()).isTrue(); + } + + @Test + void testPublisherAgreementAlreadySigned() throws Exception { + var user = mockUser(); + Mockito.when(restTemplate.postForEntity(any(String.class), any(), eq(String.class))) + .thenThrow(new HttpClientErrorException(HttpStatus.CONFLICT)); + + try { + eclipse.signPublisherAgreement(user); + fail("Expected an ErrorResultException"); + } catch (ErrorResultException exc) { + assertThat(exc.getMessage()).isEqualTo("A publisher agreement is already present for user test."); + } + } + + @Test + void testRevokePublisherAgreement() { + var user = mockUser(); + user.setEclipsePersonId("test"); + + eclipse.revokePublisherAgreement(user, null); + } + + @Test + void testRevokePublisherAgreementByAdmin() { + var user = mockUser(); + user.setEclipsePersonId("test"); + + var admin = new UserData(); + admin.setLoginName("admin"); + admin.setEclipseToken(new AuthToken("67890", null, null, null, null, null)); + Mockito.when(tokens.getActiveEclipseToken(admin)) + .thenReturn(admin.getEclipseToken()); + + eclipse.revokePublisherAgreement(user, admin); + } + + private UserData mockUser() { + var user = new UserData(); + user.setLoginName("test"); + user.setProvider("github"); + user.setEclipseToken(new AuthToken("12345", null, null, null, null, null)); + Mockito.when(tokens.getActiveEclipseToken(user)) + .thenReturn(user.getEclipseToken()); + return user; + } + + private UserData mockUserNoToken() { + var user = new UserData(); + user.setLoginName("test"); + user.setProvider("github"); + Mockito.when(tokens.getActiveEclipseToken(user)) + .thenReturn(null); + return user; + } + + private ResponseEntity mockProfileResponse() throws IOException { + try (var stream = getClass().getResourceAsStream("profile-response.json")) { + assert stream != null; + var json = new String(stream.readAllBytes(), StandardCharsets.UTF_8); + return new ResponseEntity<>(json, HttpStatus.OK); + } + } + + private ResponseEntity mockOutdatedProfileResponse() throws IOException { + try (var stream = getClass().getResourceAsStream("profile-outdated-response.json")) { + assert stream != null; + var json = new String(stream.readAllBytes(), StandardCharsets.UTF_8); + return new ResponseEntity<>(json, HttpStatus.OK); + } + } + + private ResponseEntity mockAllowedProfileResponse() throws IOException { + try (var stream = getClass().getResourceAsStream("profile-allowed-response.json")) { + assert stream != null; + var json = new String(stream.readAllBytes(), StandardCharsets.UTF_8); + return new ResponseEntity<>(json, HttpStatus.OK); + } + } + + private ResponseEntity mockAgreementResponse() throws IOException { + try (var stream = getClass().getResourceAsStream("publisher-agreement-response.json")) { + assert stream != null; + var json = new String(stream.readAllBytes(), StandardCharsets.UTF_8); + return new ResponseEntity<>(json, HttpStatus.OK); + } + } + + private ResponseEntity mockOutdatedAgreementResponse() throws IOException { + try (var stream = getClass().getResourceAsStream("publisher-agreement-outdated-response.json")) { + assert stream != null; + var json = new String(stream.readAllBytes(), StandardCharsets.UTF_8); + return new ResponseEntity<>(json, HttpStatus.OK); + } + } + + @TestConfiguration + static class TestConfig { + @Bean + TransactionTemplate transactionTemplate() { + return new MockTransactionTemplate(); + } + + @Bean + EclipseService eclipseService( + EclipseTokenService tokens, + ExtensionService extensions, + EntityManager entityManager, + RestTemplate restTemplate + ) { + return new EclipseService(tokens, extensions, entityManager, restTemplate); + } + + @Bean + ExtensionService extensionService( + EntityManager entityManager, + RepositoryService repositories, + SearchUtilService search, + CacheService cache, + LogService logs, + PublishExtensionVersionHandler publishHandler, + JobRequestScheduler scheduler, + ExtensionScanService extensionScanService, + ExtensionScanPersistenceService scanPersistenceService + ) { + return new ExtensionService( + new PublishingConfig(), + entityManager, + repositories, + search, + cache, + logs, + publishHandler, + scheduler, + extensionScanService, + scanPersistenceService); + } + + @Bean + ExtensionValidator extensionValidator() { + return new ExtensionValidator(); + } + + @Bean + StorageUtilService storageUtilService( + RepositoryService repositories, + GoogleCloudStorageService googleStorage, + AzureBlobStorageService azureStorage, + LocalStorageService localStorage, + AwsStorageService awsStorage, + DownloadCountService downloadCountService, + ExtensionDownloadMetrics downloadMetrics, + SearchUtilService search, + CacheService cache, + EntityManager entityManager, + FileCacheDurationConfig fileCacheDurationConfig, + CdnServiceConfig cdnServiceConfig + ) { + return new StorageUtilService( + repositories, + googleStorage, + azureStorage, + localStorage, + awsStorage, + downloadCountService, + downloadMetrics, + search, + cache, + entityManager, + fileCacheDurationConfig, + cdnServiceConfig); + } + + @Bean + LocalStorageService localStorageService() { + return new LocalStorageService(); + } + + @Bean + LatestExtensionVersionCacheKeyGenerator latestExtensionVersionCacheKeyGenerator() { + return new LatestExtensionVersionCacheKeyGenerator(); + } + } +} diff --git a/server/src/test/java/org/eclipsefdn/openvsx/eclipse/WithoutEclipseAutoConfigurationTest.java b/server/src/test/java/org/eclipsefdn/openvsx/eclipse/WithoutEclipseAutoConfigurationTest.java new file mode 100644 index 000000000..4b2a238b4 --- /dev/null +++ b/server/src/test/java/org/eclipsefdn/openvsx/eclipse/WithoutEclipseAutoConfigurationTest.java @@ -0,0 +1,66 @@ +/******************************************************************************** + * Copyright (c) 2026 Eclipse Foundation and others + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipsefdn.openvsx.eclipse; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.resttestclient.TestRestTemplate; +import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureTestRestTemplate; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.context.ApplicationContext; +import org.springframework.http.HttpStatus; + +import org.eclipse.openvsx.RegistryApplication; +import org.eclipse.openvsx.publish.PublisherAgreementService; +import org.eclipsefdn.openvsx.eclipse.support.AbstractRegistryIntegrationTest; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.eclipsefdn.openvsx.eclipse.EclipseFoundationIntegrationTest.publisherAgreementMappings; + +/** + * Negative test: with the auto-configuration excluded, the application must boot + * and serve like a vanilla registry, with the publisher agreement absent. + */ +@SpringBootTest( + classes = RegistryApplication.class, + webEnvironment = WebEnvironment.RANDOM_PORT, + properties = "spring.autoconfigure.exclude=org.eclipsefdn.openvsx.eclipse.EclipseFoundationAutoConfiguration" +) +@AutoConfigureTestRestTemplate +class WithoutEclipseAutoConfigurationTest extends AbstractRegistryIntegrationTest { + + @LocalServerPort + int port; + + @Autowired + TestRestTemplate restTemplate; + + @Autowired + ApplicationContext context; + + @Test + void applicationIsHealthyWithoutAgreementSupport() { + var response = restTemplate.getForEntity("http://localhost:" + port + "/user", String.class); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).contains("Not logged in."); + } + + @Test + void publisherAgreementIsAbsent() { + assertThat(context.getBeanProvider(PublisherAgreementService.class).getIfAvailable()).isNull(); + assertThat(publisherAgreementMappings(context)).isZero(); + + var swaggerConfig = restTemplate + .getForEntity("http://localhost:" + port + "/v3/api-docs/swagger-config", String.class); + assertThat(swaggerConfig.getBody()).doesNotContain("publisher-agreement"); + } +} diff --git a/server/src/test/java/org/eclipsefdn/openvsx/eclipse/support/AbstractRegistryIntegrationTest.java b/server/src/test/java/org/eclipsefdn/openvsx/eclipse/support/AbstractRegistryIntegrationTest.java new file mode 100644 index 000000000..2a9f9912a --- /dev/null +++ b/server/src/test/java/org/eclipsefdn/openvsx/eclipse/support/AbstractRegistryIntegrationTest.java @@ -0,0 +1,37 @@ +/******************************************************************************** + * Copyright (c) 2026 Eclipse Foundation and others + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipsefdn.openvsx.eclipse.support; + +import org.junit.jupiter.api.Tag; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.postgresql.PostgreSQLContainer; + +/** + * Base class for tests that boot the merged application. The PostgreSQL container + * is a JVM-wide singleton shared by every test context (same pattern as upstream's + * AbstractPostgresContainerTest). + */ +@Tag("integration") +public abstract class AbstractRegistryIntegrationTest { + + static final PostgreSQLContainer POSTGRES = new PostgreSQLContainer("postgres:16.2"); + + static { + POSTGRES.start(); + } + + @DynamicPropertySource + static void datasourceProperties(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", POSTGRES::getJdbcUrl); + registry.add("spring.datasource.username", POSTGRES::getUsername); + registry.add("spring.datasource.password", POSTGRES::getPassword); + } +} diff --git a/server/src/test/java/org/eclipsefdn/openvsx/eclipse/support/MockTransactionTemplate.java b/server/src/test/java/org/eclipsefdn/openvsx/eclipse/support/MockTransactionTemplate.java new file mode 100644 index 000000000..3eae76ef7 --- /dev/null +++ b/server/src/test/java/org/eclipsefdn/openvsx/eclipse/support/MockTransactionTemplate.java @@ -0,0 +1,32 @@ +/******************************************************************************** + * Copyright (c) 2020 TypeFox and others + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipsefdn.openvsx.eclipse.support; + +import java.io.Serial; + +import org.springframework.transaction.TransactionException; +import org.springframework.transaction.support.TransactionCallback; +import org.springframework.transaction.support.TransactionTemplate; + +public class MockTransactionTemplate extends TransactionTemplate { + + @Serial + private static final long serialVersionUID = 1L; + + @Override + public T execute(TransactionCallback action) throws TransactionException { + return action.doInTransaction(null); + } + + @Override + public void afterPropertiesSet() { + // Method override to prevent IllegalArgumentException from being thrown + } +} diff --git a/server/src/test/resources/application.yml b/server/src/test/resources/application.yml new file mode 100644 index 000000000..b15ec1656 --- /dev/null +++ b/server/src/test/resources/application.yml @@ -0,0 +1,50 @@ +spring: + jpa: + properties: + hibernate: + dialect: org.hibernate.dialect.PostgreSQLDialect + hibernate: + ddl-auto: none + session: + store-type: jdbc + jdbc: + initialize-schema: never + + security: + oauth2: + client: + registration: + github: + client-id: dummy-client-id + client-secret: dummy-client-secret + +bucket4j: + enabled: false + +jobrunr: + job-scheduler: + enabled: true + background-job-server: + enabled: false + worker-count: 1 + dashboard: + enabled: false + database: + type: sql + miscellaneous: + allow-anonymous-data-usage: false + +ovsx: + elasticsearch: + enabled: false + databasesearch: + enabled: true + storage: + local: + directory: /tmp + # same keys and values as the open-vsx.org deployment configuration + eclipse: + base-url: https://api.eclipse.org/ + publisher-agreement: + version: 1.1 + allowed-versions: "1,1.0,1.1" diff --git a/server/src/test/resources/org/eclipsefdn/openvsx/eclipse/profile-allowed-response.json b/server/src/test/resources/org/eclipsefdn/openvsx/eclipse/profile-allowed-response.json new file mode 100644 index 000000000..42beb6416 --- /dev/null +++ b/server/src/test/resources/org/eclipsefdn/openvsx/eclipse/profile-allowed-response.json @@ -0,0 +1,43 @@ +[ + { + "uid": "98765", + "name": "test", + "mail": null, + "picture": "http://my-profile-picture.com", + "eca": { + "signed": true, + "can_contribute_spec_project": true + }, + "publisher_agreements": { + "open-vsx": { + "version": "1" + } + }, + "is_committer": true, + "friends": { + "friend_id": null + }, + "first_name": "Foo", + "last_name": "Bar", + "full_name": "Foo Bar", + "github_handle": "test", + "twitter_handle": "test", + "org": "Test", + "job_title": "Software Engineer", + "website": "http://test.com", + "country": { + "code": null, + "name": null + }, + "bio": "Bla bla bla.", + "interests": [ + "Software Engineering" + ], + "working_groups_interests": [], + "forums_url": "https://api.eclipse.org/account/profile/test/forum", + "projects_url": "https://api.eclipse.org/account/profile/test/projects", + "gerrit_url": "https://api.eclipse.org/account/profile/test/gerrit", + "mailinglist_url": "https://api.eclipse.org/account/profile/test/mailing-list", + "mpc_favorites_url": "https://api.eclipse.org/marketplace/favorites/?name=test" + } +] diff --git a/server/src/test/resources/org/eclipsefdn/openvsx/eclipse/profile-outdated-response.json b/server/src/test/resources/org/eclipsefdn/openvsx/eclipse/profile-outdated-response.json new file mode 100644 index 000000000..d43d1085d --- /dev/null +++ b/server/src/test/resources/org/eclipsefdn/openvsx/eclipse/profile-outdated-response.json @@ -0,0 +1,43 @@ +[ + { + "uid": "98765", + "name": "test", + "mail": null, + "picture": "http://my-profile-picture.com", + "eca": { + "signed": true, + "can_contribute_spec_project": true + }, + "publisher_agreements": { + "open-vsx": { + "version": "0.1" + } + }, + "is_committer": true, + "friends": { + "friend_id": null + }, + "first_name": "Foo", + "last_name": "Bar", + "full_name": "Foo Bar", + "github_handle": "test", + "twitter_handle": "test", + "org": "Test", + "job_title": "Software Engineer", + "website": "http://test.com", + "country": { + "code": null, + "name": null + }, + "bio": "Bla bla bla.", + "interests": [ + "Software Engineering" + ], + "working_groups_interests": [], + "forums_url": "https://api.eclipse.org/account/profile/test/forum", + "projects_url": "https://api.eclipse.org/account/profile/test/projects", + "gerrit_url": "https://api.eclipse.org/account/profile/test/gerrit", + "mailinglist_url": "https://api.eclipse.org/account/profile/test/mailing-list", + "mpc_favorites_url": "https://api.eclipse.org/marketplace/favorites/?name=test" + } +] diff --git a/server/src/test/resources/org/eclipsefdn/openvsx/eclipse/profile-response.json b/server/src/test/resources/org/eclipsefdn/openvsx/eclipse/profile-response.json new file mode 100644 index 000000000..b352a80db --- /dev/null +++ b/server/src/test/resources/org/eclipsefdn/openvsx/eclipse/profile-response.json @@ -0,0 +1,43 @@ +[ + { + "uid": "98765", + "name": "test", + "mail": null, + "picture": "http://my-profile-picture.com", + "eca": { + "signed": true, + "can_contribute_spec_project": true + }, + "publisher_agreements": { + "open-vsx": { + "version": "1.1" + } + }, + "is_committer": true, + "friends": { + "friend_id": null + }, + "first_name": "Foo", + "last_name": "Bar", + "full_name": "Foo Bar", + "github_handle": "test", + "twitter_handle": "test", + "org": "Test", + "job_title": "Software Engineer", + "website": "http://test.com", + "country": { + "code": null, + "name": null + }, + "bio": "Bla bla bla.", + "interests": [ + "Software Engineering" + ], + "working_groups_interests": [], + "forums_url": "https://api.eclipse.org/account/profile/test/forum", + "projects_url": "https://api.eclipse.org/account/profile/test/projects", + "gerrit_url": "https://api.eclipse.org/account/profile/test/gerrit", + "mailinglist_url": "https://api.eclipse.org/account/profile/test/mailing-list", + "mpc_favorites_url": "https://api.eclipse.org/marketplace/favorites/?name=test" + } +] diff --git a/server/src/test/resources/org/eclipsefdn/openvsx/eclipse/publisher-agreement-outdated-response.json b/server/src/test/resources/org/eclipsefdn/openvsx/eclipse/publisher-agreement-outdated-response.json new file mode 100644 index 000000000..da7a90d34 --- /dev/null +++ b/server/src/test/resources/org/eclipsefdn/openvsx/eclipse/publisher-agreement-outdated-response.json @@ -0,0 +1,12 @@ +{ + "PersonID": "test", + "DocumentID": "abcd", + "Version": "0.1", + "EffectiveDate": "2020-10-09 05:10:32", + "ReceivedDate": "2020-10-09", + "ExpirationDate": null, + "ScannedDocumentBLOB": null, + "ScannedDocumentMime": "application/json", + "ScannedDocumentBytes": "117", + "ScannedDocumentFileName": "openvsx-publisher-agreement.json" +} diff --git a/server/src/test/resources/org/eclipsefdn/openvsx/eclipse/publisher-agreement-response.json b/server/src/test/resources/org/eclipsefdn/openvsx/eclipse/publisher-agreement-response.json new file mode 100644 index 000000000..2579c6453 --- /dev/null +++ b/server/src/test/resources/org/eclipsefdn/openvsx/eclipse/publisher-agreement-response.json @@ -0,0 +1,12 @@ +{ + "PersonID": "test", + "DocumentID": "abcd", + "Version": "1.1", + "EffectiveDate": "2020-10-09 05:10:32", + "ReceivedDate": "2020-10-09", + "ExpirationDate": null, + "ScannedDocumentBLOB": null, + "ScannedDocumentMime": "application/json", + "ScannedDocumentBytes": "117", + "ScannedDocumentFileName": "openvsx-publisher-agreement.json" +} diff --git a/server/upstream b/server/upstream index a596db555..b0cc7f2da 160000 --- a/server/upstream +++ b/server/upstream @@ -1 +1 @@ -Subproject commit a596db555c47efa9c63347d444142d064c1f813e +Subproject commit b0cc7f2da381843b58c7479e515f7167e74b4bf8