diff --git a/server/src/main/java/org/eclipse/openvsx/metrics/DownloadCountValidator.java b/server/src/main/java/org/eclipse/openvsx/metrics/DownloadCountValidator.java new file mode 100644 index 000000000..465e992c8 --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/metrics/DownloadCountValidator.java @@ -0,0 +1,220 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipse.openvsx.metrics; + +import jakarta.servlet.http.HttpServletRequest; +import org.eclipse.openvsx.entities.Extension; +import org.eclipse.openvsx.metrics.config.DownloadCountValidationProperties; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.expression.ExpressionParser; +import org.springframework.expression.spel.standard.SpelExpressionParser; +import org.springframework.stereotype.Service; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.Duration; +import java.time.Instant; +import java.util.HexFormat; +import java.util.Locale; + +/** + * Validates whether a download should increment the download count. + * Prevents malicious actors from inflating download counts through: + * - Per-IP per-extension hourly rate limiting (hourly-limit-per-ip) + * - Filtering out automated/bot downloads + * + * Only active when Redis is enabled (ovsx.redis.enabled=true). + * When Redis is unavailable, all downloads are counted (no filtering). + */ +@Service +@ConditionalOnProperty(value = "ovsx.redis.enabled", havingValue = "true") +public class DownloadCountValidator { + + private static final Logger logger = LoggerFactory.getLogger(DownloadCountValidator.class); + + private final StringRedisTemplate redisTemplate; + private final DownloadCountValidationProperties properties; + private final ExpressionParser expressionParser = new SpelExpressionParser(); + + /** + * SpEL expression evaluated against the HttpServletRequest to extract the client IP. + * Shares the same property as the rate limiter (ovsx.rate-limit.ip-address-function) + * so both systems resolve IPs consistently. + * + * Default is getRemoteAddr() (TCP source IP — safe, but returns the proxy IP + * when behind a reverse proxy). Override in application.yml for proxied deployments. + */ + private final String ipAddressFunction; + + public DownloadCountValidator( + StringRedisTemplate redisTemplate, + @Value("${ovsx.rate-limit.ip-address-function:getRemoteAddr()}") String ipAddressFunction, + DownloadCountValidationProperties properties + ) { + this.redisTemplate = redisTemplate; + this.ipAddressFunction = ipAddressFunction; + this.properties = properties; + } + + /** + * Determines if this download should increment the extension's download count. + */ + public boolean shouldCountDownload(Extension extension, HttpServletRequest request) { + if (!isValidationEnabled()) { + return true; + } + + if (request == null) { + // Fail closed when validation is enabled but request context is missing. + return false; + } + + String userAgent = extractUserAgent(request); + String ipAddress = extractClientIp(request); + // API download flow does not carry a log event timestamp, so use request time + // as event-time for dedup bucketing. + return shouldCountDownload(extension.getId(), ipAddress, userAgent, Instant.now()); + } + + /** + * Determines if a download should be counted for non-request contexts + * using event-time bucketing. + *

+ * The Redis key includes a time bucket computed from the event timestamp. + * This makes dedup independent of when the handler runs. + */ + public boolean shouldCountDownload(Long extensionId, String clientIp, String userAgent, Instant eventTime) { + if (!isValidationEnabled()) { + return true; + } + + if (isAutomatedClient(userAgent)) { + return false; + } + + if (clientIp == null || clientIp.isBlank()) { + // Fail closed when validation is enabled but client IP cannot be resolved. + return false; + } + + if (eventTime == null) { + // Event-time bucketing is required; without an event timestamp the event + // cannot be placed in a deterministic dedup bucket. + return false; + } + + return isUnderHourlyLimit(clientIp, extensionId, eventTime); + } + + public boolean isValidationEnabled() { + return properties.getEnabled(); + } + + /** + * Checks whether this download is within the per-IP per-extension hourly limit. + *

+ * Redis key: {@code {prefix}:{hashedIp}:{extensionId}:{hourBucket}} + * TTL: 1 hour + {@code lateArrivalHours} so late CDN log entries can still + * deduplicate against the correct bucket after the hour rolls over. + *

+ * The key is created with the TTL atomically via SET NX before incrementing, + * so the TTL is always set at creation with no race window. + */ + private boolean isUnderHourlyLimit(String ipAddress, Long extensionId, Instant eventTime) { + String key = buildRateLimitKey(ipAddress, extensionId, eventTime); + Duration ttl = Duration.ofHours(1).plusHours(properties.getLateArrivalHours()); + + // Create the key with TTL atomically if it doesn't exist yet. + // This guarantees the TTL is always set before any increment happens. + redisTemplate.opsForValue().setIfAbsent(key, "0", ttl); + + Long count = redisTemplate.opsForValue().increment(key); + return count != null && count <= properties.getHourlyLimitPerIp(); + } + + /** + * Builds the Redis key for per-IP per-extension hourly rate limiting. + *

+ * Format: {@code {prefix}:{hashedIp}:{extensionId}:{hourBucket}} + * where {@code hourBucket} is the event timestamp divided by 3600 (epoch seconds), + * so all events from the same IP + extension within the same clock-hour share one counter. + */ + private String buildRateLimitKey(String ipAddress, Long extensionId, Instant eventTime) { + // Truncate to the hour so all events in the same hour share one key. + long hourBucket = eventTime.getEpochSecond() / 3600; + return String.format("%s:%s:%d:%d", + properties.getKeyPrefix(), + hashIp(ipAddress), + extensionId, + hourBucket + ); + } + + private String hashIp(String ipAddress) { + try { + var digest = MessageDigest.getInstance("SHA-256"); + byte[] hash = digest.digest(ipAddress.getBytes(StandardCharsets.UTF_8)); + return HexFormat.of().formatHex(hash, 0, 8); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 not available", e); + } + } + + /** + * Extracts client IP by evaluating the same SpEL expression used by the rate limiter. + * The expression runs against the HttpServletRequest as root object, so methods + * like getHeader(), getRemoteAddr(), getParameter() are all available. + */ + private String extractClientIp(HttpServletRequest request) { + try { + var expr = expressionParser.parseExpression(ipAddressFunction); + var result = expr.getValue(request, String.class); + if (result != null && !result.isEmpty()) { + return result; + } + } catch (Exception e) { + logger.warn("Failed to evaluate ip-address-function '{}': {}", ipAddressFunction, e.getMessage()); + } + + // Fallback to TCP source IP if the expression fails or returns empty + String remoteAddr = request.getRemoteAddr(); + return (remoteAddr != null && !remoteAddr.isEmpty()) ? remoteAddr : null; + } + + private String extractUserAgent(HttpServletRequest request) { + String userAgent = request.getHeader("User-Agent"); + return (userAgent != null && !userAgent.isEmpty()) ? userAgent : null; + } + + /** + * Heuristic check for automated HTTP clients. + * These downloads are served normally but don't inflate the count. + */ + private boolean isAutomatedClient(String userAgent) { + if (userAgent == null || userAgent.isEmpty()) { + return false; + } + + String ua = userAgent.toLowerCase(Locale.ROOT); + return properties.getAutomatedClientKeywords().stream() + .filter(keyword -> keyword != null && !keyword.isBlank()) + .map(keyword -> keyword.toLowerCase(Locale.ROOT)) + .anyMatch(ua::contains); + } + +} diff --git a/server/src/main/java/org/eclipse/openvsx/metrics/config/DownloadCountValidationProperties.java b/server/src/main/java/org/eclipse/openvsx/metrics/config/DownloadCountValidationProperties.java new file mode 100644 index 000000000..cba9e59ac --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/metrics/config/DownloadCountValidationProperties.java @@ -0,0 +1,156 @@ +/** + * ****************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + * ****************************************************************************** + */ +package org.eclipse.openvsx.metrics.config; + +import jakarta.annotation.PostConstruct; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Configuration; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +@Configuration +public class DownloadCountValidationProperties { + + private static final Logger logger = LoggerFactory.getLogger(DownloadCountValidationProperties.class); + + /** + * Master toggle for download count validation. + * When false, all downloads are counted. + */ + @Value("${ovsx.download-count.validation.enabled:false}") + private boolean enabled; + + /** + * Maximum number of downloads counted per IP per extension per hour. + * Downloads beyond this limit within the same hour window are ignored. + * Default: 3 — allows a few legitimate re-downloads per hour while + * preventing bulk inflation from a single IP. + */ + @Value("${ovsx.download-count.validation.hourly-limit-per-ip:50}") + private int hourlyLimitPerIp; + + /** + * Redis key prefix used for deduplication entries. + */ + @Value("${ovsx.download-count.validation.key-prefix:download:dedup}") + private String keyPrefix; + + /** + * Extra hours added to the Redis TTL beyond the dedup window when event-time bucketing is on. + * Covers late log delivery so out-of-order events still dedup correctly. + * + * Default: {@code 24} + */ + @Value("${ovsx.download-count.validation.late-arrival-hours:2}") + private int lateArrivalHours; + + /** + * User-Agent substrings treated as automated clients. + */ + @Value("${ovsx.download-count.validation.automated-client-keywords:}") + private String automatedClientKeywordsValue; + + private List automatedClientKeywords = new ArrayList<>(); + + /** + * Validates the configuration at startup. + * Fails fast with a clear message rather than letting bad config cause + * subtle runtime bugs (e.g. negative TTLs, ineffective late-arrival buffer). + */ + @PostConstruct + public void validate() { + // hourly-limit-per-ip must be at least 1. + if (hourlyLimitPerIp < 1) { + throw new IllegalStateException( + "ovsx.download-count.validation.hourly-limit-per-ip must be >= 1, got: " + hourlyLimitPerIp); + } + + // late-arrival-hours must be non-negative. + if (lateArrivalHours < 0) { + throw new IllegalStateException( + "ovsx.download-count.validation.late-arrival-hours must be >= 0, got: " + lateArrivalHours); + } + + // The late-arrival buffer should be at least 1 hour so that log entries + // arriving after the top of the hour still dedup against the correct bucket. + if (lateArrivalHours < 1) { + logger.warn("ovsx.download-count.validation.late-arrival-hours ({}) is less than 1 hour. " + + "Late CDN log entries may not deduplicate correctly against the hourly bucket.", + lateArrivalHours); + } + + // key-prefix must not be blank — an empty prefix would produce malformed Redis keys. + if (keyPrefix == null || keyPrefix.isBlank()) { + throw new IllegalStateException( + "ovsx.download-count.validation.key-prefix must not be blank"); + } + } + + public boolean getEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public int getHourlyLimitPerIp() { + return hourlyLimitPerIp; + } + + public void setHourlyLimitPerIp(int hourlyLimitPerIp) { + this.hourlyLimitPerIp = hourlyLimitPerIp; + } + + public String getKeyPrefix() { + return keyPrefix; + } + + public void setKeyPrefix(String keyPrefix) { + this.keyPrefix = keyPrefix; + } + + public int getLateArrivalHours() { + return lateArrivalHours; + } + + public void setLateArrivalHours(int lateArrivalHours) { + this.lateArrivalHours = lateArrivalHours; + } + + public List getAutomatedClientKeywords() { + if (automatedClientKeywords.isEmpty() && automatedClientKeywordsValue != null) { + String normalized = automatedClientKeywordsValue + .replace("[", "") + .replace("]", "") + .replace("\"", "") + .replace("'", ""); + automatedClientKeywords = Arrays.stream(normalized.split(",")) + .map(String::trim) + .filter(value -> !value.isEmpty()) + .toList(); + } + return automatedClientKeywords; + } + + public void setAutomatedClientKeywords(List automatedClientKeywords) { + this.automatedClientKeywords = automatedClientKeywords; + } + +} diff --git a/server/src/main/java/org/eclipse/openvsx/storage/StorageUtilService.java b/server/src/main/java/org/eclipse/openvsx/storage/StorageUtilService.java index c25a80d1d..b70b60f7e 100644 --- a/server/src/main/java/org/eclipse/openvsx/storage/StorageUtilService.java +++ b/server/src/main/java/org/eclipse/openvsx/storage/StorageUtilService.java @@ -19,6 +19,7 @@ import org.eclipse.openvsx.entities.ExtensionVersion; import org.eclipse.openvsx.entities.FileResource; import org.eclipse.openvsx.entities.Namespace; +import org.eclipse.openvsx.metrics.DownloadCountValidator; import org.eclipse.openvsx.metrics.ExtensionDownloadMetrics; import org.eclipse.openvsx.repositories.RepositoryService; import org.eclipse.openvsx.search.SearchUtilService; @@ -31,6 +32,7 @@ import org.springframework.stereotype.Component; import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody; +import jakarta.servlet.http.HttpServletRequest; import java.io.IOException; import java.net.URI; import java.nio.file.Files; @@ -38,6 +40,8 @@ import java.util.*; import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; import static org.eclipse.openvsx.entities.FileResource.*; import static org.eclipse.openvsx.util.UrlUtil.createApiFileUrl; @@ -61,6 +65,7 @@ public class StorageUtilService implements IStorageService { private final EntityManager entityManager; private final FileCacheDurationConfig fileCacheDurationConfig; private final CdnServiceConfig cdnServiceConfig; + private final Optional downloadCountValidator; /** Determines which external storage service to use in case multiple services are configured. */ @Value("${ovsx.storage.primary-service:}") @@ -82,7 +87,8 @@ public StorageUtilService( CacheService cache, EntityManager entityManager, FileCacheDurationConfig fileCacheDurationConfig, - CdnServiceConfig cdnServiceConfig + CdnServiceConfig cdnServiceConfig, + Optional downloadCountValidator ) { this.repositories = repositories; this.googleStorage = googleStorage; @@ -96,6 +102,7 @@ public StorageUtilService( this.entityManager = entityManager; this.fileCacheDurationConfig = fileCacheDurationConfig; this.cdnServiceConfig = cdnServiceConfig; + this.downloadCountValidator = downloadCountValidator; } public boolean shouldStoreExternally(FileResource resource) { @@ -291,6 +298,14 @@ public void increaseDownloadCount(FileResource resource) { var managedResource = entityManager.find(FileResource.class, resource.getId()); var extension = managedResource.getExtension().getExtension(); + + if (downloadCountValidator.isPresent() && downloadCountValidator.get().isValidationEnabled()) { + var request = getCurrentHttpRequest(); + if (!downloadCountValidator.get().shouldCountDownload(extension, request)) { + return; + } + } + extension.setDownloadCount(extension.getDownloadCount() + 1); cache.evictNamespaceDetails(extension); @@ -300,6 +315,18 @@ public void increaseDownloadCount(FileResource resource) { } } + /** + * Gets the current HttpServletRequest from Spring's RequestContextHolder. + * Returns null if called outside a servlet request context (e.g., batch jobs). + */ + private HttpServletRequest getCurrentHttpRequest() { + var attrs = RequestContextHolder.getRequestAttributes(); + if (attrs instanceof ServletRequestAttributes servletAttrs) { + return servletAttrs.getRequest(); + } + return null; + } + public ResponseEntity getFileResponse(FileResource resource) { if (resource.getStorageType().equals(STORAGE_LOCAL)) { return localStorage.getFile(resource); diff --git a/server/src/main/java/org/eclipse/openvsx/storage/log/AwsDownloadCountHandler.java b/server/src/main/java/org/eclipse/openvsx/storage/log/AwsDownloadCountHandler.java index 6eb90c01b..72ace70b6 100644 --- a/server/src/main/java/org/eclipse/openvsx/storage/log/AwsDownloadCountHandler.java +++ b/server/src/main/java/org/eclipse/openvsx/storage/log/AwsDownloadCountHandler.java @@ -12,6 +12,7 @@ import org.apache.commons.lang3.StringUtils; import org.eclipse.openvsx.entities.Extension; import org.eclipse.openvsx.entities.FileResource; +import org.eclipse.openvsx.metrics.DownloadCountValidator; import org.eclipse.openvsx.migration.HandlerJobRequest; import org.eclipse.openvsx.storage.AwsStorageService; import org.eclipse.openvsx.util.TempFile; @@ -33,9 +34,11 @@ import java.nio.file.Files; import java.nio.file.StandardCopyOption; import java.time.LocalDateTime; +import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.stream.Collectors; import java.util.zip.GZIPInputStream; @@ -63,6 +66,7 @@ public class AwsDownloadCountHandler implements JobRequestHandler downloadCountValidator; @Value("${ovsx.logs.aws.bucket:}") String bucket; @@ -81,9 +85,14 @@ public class AwsDownloadCountHandler implements JobRequestHandler downloadCountValidator + ) { this.awsStorageService = awsStorageService; this.processor = processor; + this.downloadCountValidator = downloadCountValidator; } @PostConstruct @@ -222,7 +231,7 @@ private Map processLogFile(String fileName) throws IOException var gzipStream = new GZIPInputStream(fileStream); var reader = new BufferedReader(new InputStreamReader(gzipStream, StandardCharsets.UTF_8)); ) { - var fileCounts = new HashMap(); + var events = new ArrayList(); var lines = reader.lines().iterator(); while (lines.hasNext()) { var line = lines.next(); @@ -236,11 +245,39 @@ var record = logFileParser.parse(line); var uri = record.url(); var uriComponents = uri.split("/"); var vsixFile = UriUtils.decode(uriComponents[uriComponents.length - 1], StandardCharsets.UTF_8).toUpperCase(); - fileCounts.merge(vsixFile, 1, Integer::sum); + events.add(new DownloadEvent(vsixFile, record.clientIp(), record.userAgent(), record.eventTime())); } } + return countValidatedEvents(events); + } + } + + Map countValidatedEvents(List events) { + var fileCounts = new HashMap(); + if (events.isEmpty()) { return fileCounts; } + + var fileNames = events.stream() + .map(DownloadEvent::fileName) + .distinct() + .toList(); + var fileToExtensionId = processor.resolveDownloadFileToExtensionId(FileResource.STORAGE_AWS, fileNames); + + for (var event : events) { + Long extensionId = fileToExtensionId.get(event.fileName()); + if (extensionId == null) { + continue; + } + + boolean shouldCount = downloadCountValidator + .map(validator -> validator.shouldCountDownload(extensionId, event.clientIp(), event.userAgent(), event.eventTime())) + .orElse(true); + if (shouldCount) { + fileCounts.merge(event.fileName(), 1, Integer::sum); + } + } + return fileCounts; } private boolean isGetOperation(LogRecord record) { @@ -282,4 +319,7 @@ private ListObjectsV2Response listObjects(String continuationToken) { return getS3Client().listObjectsV2(builder.build()); } + + record DownloadEvent(String fileName, String clientIp, String userAgent, java.time.Instant eventTime) { + } } diff --git a/server/src/main/java/org/eclipse/openvsx/storage/log/AzureDownloadCountHandler.java b/server/src/main/java/org/eclipse/openvsx/storage/log/AzureDownloadCountHandler.java index f11855567..1a81cf8e6 100644 --- a/server/src/main/java/org/eclipse/openvsx/storage/log/AzureDownloadCountHandler.java +++ b/server/src/main/java/org/eclipse/openvsx/storage/log/AzureDownloadCountHandler.java @@ -21,10 +21,10 @@ import com.fasterxml.jackson.databind.ObjectMapper; import org.apache.commons.lang3.StringUtils; import org.eclipse.openvsx.entities.FileResource; +import org.eclipse.openvsx.metrics.DownloadCountValidator; import org.eclipse.openvsx.migration.HandlerJobRequest; import org.eclipse.openvsx.util.TempFile; import org.jobrunr.jobs.annotations.Job; -import org.jobrunr.jobs.annotations.Recurring; import org.jobrunr.jobs.lambdas.JobRequestHandler; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -40,10 +40,13 @@ import java.nio.file.Files; import java.time.Duration; import java.time.LocalDateTime; +import java.time.Instant; +import java.time.format.DateTimeParseException; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.regex.Pattern; import static org.eclipse.openvsx.storage.AzureBlobStorageService.AZURE_USER_AGENT; @@ -58,6 +61,7 @@ public class AzureDownloadCountHandler implements JobRequestHandler downloadCountValidator; private BlobContainerClient containerClient; private ObjectMapper objectMapper; private Pattern blobItemNamePattern; @@ -80,8 +84,12 @@ public class AzureDownloadCountHandler implements JobRequestHandler downloadCountValidator + ) { this.processor = processor; + this.downloadCountValidator = downloadCountValidator; } public String getRecurringJobId() { @@ -194,7 +202,7 @@ private Map processBlobItem(String blobName) throws IOException var downloadsTempFile = downloadBlobItem(blobName); var reader = Files.newBufferedReader(downloadsTempFile.getPath()) ) { - var fileCounts = new HashMap(); + var events = new ArrayList(); var lines = reader.lines().iterator(); while(lines.hasNext()) { var line = lines.next(); @@ -206,11 +214,43 @@ private Map processBlobItem(String blobName) throws IOException } if(pathParams != null && storageBlobContainer.equals(pathParams[1])) { var fileName = UriUtils.decode(pathParams[pathParams.length - 1], StandardCharsets.UTF_8).toUpperCase(); - fileCounts.merge(fileName, 1, Integer::sum); + String clientIp = node.path("callerIpAddress").asText(null); + String userAgent = node.path("properties").path("userAgentHeader").asText(null); + // Azure blob log lines have a root "time" field in ISO-8601 UTC format. + Instant eventTime = parseEventTime(node.path("time").asText(null)); + events.add(new DownloadEvent(fileName, clientIp, userAgent, eventTime)); } } + return countValidatedEvents(events); + } + } + + Map countValidatedEvents(List events) { + var fileCounts = new HashMap(); + if (events.isEmpty()) { return fileCounts; } + + var fileNames = events.stream() + .map(DownloadEvent::fileName) + .distinct() + .toList(); + var fileToExtensionId = processor.resolveDownloadFileToExtensionId(FileResource.STORAGE_AZURE, fileNames); + + for (var event : events) { + Long extensionId = fileToExtensionId.get(event.fileName()); + if (extensionId == null) { + continue; + } + + boolean shouldCount = downloadCountValidator + .map(validator -> validator.shouldCountDownload(extensionId, event.clientIp(), event.userAgent(), event.eventTime())) + .orElse(true); + if (shouldCount) { + fileCounts.merge(event.fileName(), 1, Integer::sum); + } + } + return fileCounts; } private boolean isGetBlobOperation(JsonNode node) { @@ -298,4 +338,23 @@ private Pattern getBlobItemNamePattern() { return blobItemNamePattern; } + + /** + * Parses Azure blob log ISO-8601 UTC timestamp (e.g. "2026-02-09T04:20:50Z") to Instant. + * Returns null on parse failure so callers can fall back gracefully. + */ + private Instant parseEventTime(String timestamp) { + if (timestamp == null || timestamp.isBlank()) { + return null; + } + try { + return Instant.parse(timestamp); + } catch (DateTimeParseException e) { + logger.debug("Failed to parse Azure event timestamp: {}", timestamp); + return null; + } + } + + record DownloadEvent(String fileName, String clientIp, String userAgent, Instant eventTime) { + } } diff --git a/server/src/main/java/org/eclipse/openvsx/storage/log/CloudFrontLogFileParser.java b/server/src/main/java/org/eclipse/openvsx/storage/log/CloudFrontLogFileParser.java index 73ea9e8a7..8679eaeea 100644 --- a/server/src/main/java/org/eclipse/openvsx/storage/log/CloudFrontLogFileParser.java +++ b/server/src/main/java/org/eclipse/openvsx/storage/log/CloudFrontLogFileParser.java @@ -12,7 +12,19 @@ *****************************************************************************/ package org.eclipse.openvsx.storage.log; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalTime; +import java.time.ZoneOffset; +import java.time.format.DateTimeParseException; + class CloudFrontLogFileParser implements LogFileParser { + + private static final Logger logger = LoggerFactory.getLogger(CloudFrontLogFileParser.class); + @Override public LogRecord parse(String line) { if (line.startsWith("#")) { @@ -22,6 +34,29 @@ public LogRecord parse(String line) { // Format: // date time x-edge-location sc-bytes c-ip cs-method cs(Host) cs-uri-stem sc-status cs(Referer) cs(User-Agent) cs-uri-query cs(Cookie) x-edge-result-type x-edge-request-id x-host-header cs-protocol cs-bytes time-taken x-forwarded-for ssl-protocol ssl-cipher x-edge-response-result-type cs-protocol-version fle-status fle-encrypted-fields c-port time-to-first-byte x-edge-detailed-result-type sc-content-type sc-content-len sc-range-start sc-range-end var components = line.split("[ \t]+"); - return new LogRecord(components[5], Integer.parseInt(components[8]), components[7]); + return new LogRecord( + components[5], + Integer.parseInt(components[8]), + components[7], + components[4], + components[10], + parseEventTime(components[0], components[1]) + ); + } + + /** + * Parses CloudFront date and time columns into a UTC Instant. + * CloudFront logs always use UTC for these fields. + * Returns null if parsing fails, so callers can fall back gracefully. + */ + private Instant parseEventTime(String date, String time) { + try { + return LocalDate.parse(date) + .atTime(LocalTime.parse(time)) + .toInstant(ZoneOffset.UTC); + } catch (DateTimeParseException e) { + logger.debug("Failed to parse CloudFront event time: date={}, time={}", date, time); + return null; + } } } diff --git a/server/src/main/java/org/eclipse/openvsx/storage/log/DownloadCountProcessor.java b/server/src/main/java/org/eclipse/openvsx/storage/log/DownloadCountProcessor.java index 40b27213b..3f53195fb 100644 --- a/server/src/main/java/org/eclipse/openvsx/storage/log/DownloadCountProcessor.java +++ b/server/src/main/java/org/eclipse/openvsx/storage/log/DownloadCountProcessor.java @@ -24,6 +24,7 @@ import org.springframework.stereotype.Component; import java.time.LocalDateTime; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.stream.Collectors; @@ -75,6 +76,18 @@ public Map processDownloadCounts(String storageType, Map resolveDownloadFileToExtensionId(String storageType, List fileNames) { + return Observation.createNotStarted("DownloadCountProcessor#resolveDownloadFileToExtensionId", observations).observe(() -> { + var fileToExtension = new HashMap(); + repositories.findDownloadsByStorageTypeAndName(storageType, fileNames).forEach(fileResource -> { + String normalizedFileName = fileResource.getName().toUpperCase(); + Long extensionId = fileResource.getExtension().getExtension().getId(); + fileToExtension.put(normalizedFileName, extensionId); + }); + return fileToExtension; + }); + } + @Transactional public List increaseDownloadCounts(Map extensionDownloads) { return Observation.createNotStarted("DownloadCountProcessor#increaseDownloadCounts", observations).observe(() -> { diff --git a/server/src/main/java/org/eclipse/openvsx/storage/log/DownloadCountService.java b/server/src/main/java/org/eclipse/openvsx/storage/log/DownloadCountService.java index 936daf33f..8e2d0f0b5 100644 --- a/server/src/main/java/org/eclipse/openvsx/storage/log/DownloadCountService.java +++ b/server/src/main/java/org/eclipse/openvsx/storage/log/DownloadCountService.java @@ -77,7 +77,7 @@ public void applicationStarted(ApplicationStartedEvent event) { azureDownloadCountHandler.getRecurringJobId(), azureDownloadCountHandler.getCronSchedule(), ZoneId.of("UTC"), - new HandlerJobRequest<>(AwsDownloadCountHandler.class) + new HandlerJobRequest<>(AzureDownloadCountHandler.class) ); } else { scheduler.deleteRecurringJob(azureDownloadCountHandler.getRecurringJobId()); diff --git a/server/src/main/java/org/eclipse/openvsx/storage/log/FastlyLogFileParser.java b/server/src/main/java/org/eclipse/openvsx/storage/log/FastlyLogFileParser.java index f7d1e135d..808fa424a 100644 --- a/server/src/main/java/org/eclipse/openvsx/storage/log/FastlyLogFileParser.java +++ b/server/src/main/java/org/eclipse/openvsx/storage/log/FastlyLogFileParser.java @@ -24,6 +24,10 @@ import jakarta.annotation.Nullable; import java.io.IOException; +import java.time.Instant; +import java.time.OffsetDateTime; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; class FastlyLogFileParser implements LogFileParser { private final Logger logger = LoggerFactory.getLogger(FastlyLogFileParser.class); @@ -56,6 +60,8 @@ public FastlyLogFileParser() { class LogRecordDeserializer extends StdDeserializer { + private static final Logger logger = LoggerFactory.getLogger(LogRecordDeserializer.class); + public LogRecordDeserializer() { this(null); } @@ -71,6 +77,39 @@ public LogRecord deserialize(JsonParser jp, DeserializationContext ctxt) String operation = node.get("request_method").asText(); int status = (Integer) node.get("response_status").numberValue(); String url = node.get("url").asText(); - return new LogRecord(operation, status, url); + String clientIp = node.path("client_ip").asText(null); + String userAgent = node.path("request_user_agent").asText(null); + Instant eventTime = parseEventTime(node.path("timestamp").asText(null)); + return new LogRecord(operation, status, url, clientIp, userAgent, eventTime); + } + + /** + * Parses Fastly timestamp to UTC Instant. + * + * Fastly uses the basic-offset ISO-8601 form "2026-02-09T04:20:50+0000" + * (no colon in offset) which the default ISO parser rejects. + * We try standard ISO first (covers "+00:00" and "Z"), then fall back + * to a basic-offset formatter (covers "+0000"). + */ + private static final DateTimeFormatter FASTLY_TS_FORMATTER = + DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ssXX"); + + private Instant parseEventTime(String timestamp) { + if (timestamp == null || timestamp.isBlank()) { + return null; + } + // Try standard ISO offset first ("+00:00", "Z") + try { + return OffsetDateTime.parse(timestamp).toInstant(); + } catch (DateTimeParseException ignored) { + // fall through + } + // Try basic offset format ("+0000") used by Fastly + try { + return OffsetDateTime.parse(timestamp, FASTLY_TS_FORMATTER).toInstant(); + } catch (DateTimeParseException e) { + logger.debug("Failed to parse Fastly event timestamp: {}", timestamp); + return null; + } } } diff --git a/server/src/main/java/org/eclipse/openvsx/storage/log/LogRecord.java b/server/src/main/java/org/eclipse/openvsx/storage/log/LogRecord.java index 18e4febe8..bba37596f 100644 --- a/server/src/main/java/org/eclipse/openvsx/storage/log/LogRecord.java +++ b/server/src/main/java/org/eclipse/openvsx/storage/log/LogRecord.java @@ -13,5 +13,24 @@ package org.eclipse.openvsx.storage.log; import jakarta.annotation.Nonnull; +import jakarta.annotation.Nullable; -public record LogRecord(@Nonnull String method, int status, @Nonnull String url) {} +import java.time.Instant; + +public record LogRecord( + @Nonnull String method, + int status, + @Nonnull String url, + @Nullable String clientIp, + @Nullable String userAgent, + @Nullable Instant eventTime +) { + public LogRecord(@Nonnull String method, int status, @Nonnull String url) { + this(method, status, url, null, null, null); + } + + public LogRecord(@Nonnull String method, int status, @Nonnull String url, + @Nullable String clientIp, @Nullable String userAgent) { + this(method, status, url, clientIp, userAgent, null); + } +} diff --git a/server/src/test/java/org/eclipse/openvsx/RegistryAPITest.java b/server/src/test/java/org/eclipse/openvsx/RegistryAPITest.java index 8950b37a8..fc770d2d0 100644 --- a/server/src/test/java/org/eclipse/openvsx/RegistryAPITest.java +++ b/server/src/test/java/org/eclipse/openvsx/RegistryAPITest.java @@ -2661,7 +2661,8 @@ StorageUtilService storageUtilService( cache, entityManager, fileCacheDurationConfig, - cdnServiceConfig + cdnServiceConfig, + Optional.empty() ); } diff --git a/server/src/test/java/org/eclipse/openvsx/adapter/VSCodeAPITest.java b/server/src/test/java/org/eclipse/openvsx/adapter/VSCodeAPITest.java index 5e611e7bc..59fa380f1 100644 --- a/server/src/test/java/org/eclipse/openvsx/adapter/VSCodeAPITest.java +++ b/server/src/test/java/org/eclipse/openvsx/adapter/VSCodeAPITest.java @@ -1114,7 +1114,8 @@ StorageUtilService storageUtilService( cache, entityManager, fileCacheDurationConfig, - cdnServiceConfig + cdnServiceConfig, + Optional.empty() ); } diff --git a/server/src/test/java/org/eclipse/openvsx/admin/AdminAPITest.java b/server/src/test/java/org/eclipse/openvsx/admin/AdminAPITest.java index eff82205f..94d9f458a 100644 --- a/server/src/test/java/org/eclipse/openvsx/admin/AdminAPITest.java +++ b/server/src/test/java/org/eclipse/openvsx/admin/AdminAPITest.java @@ -1616,7 +1616,8 @@ StorageUtilService storageUtilService( cache, entityManager, fileCacheDurationConfig, - cdnServiceConfig + cdnServiceConfig, + Optional.empty() ); } diff --git a/server/src/test/java/org/eclipse/openvsx/eclipse/EclipseServiceTest.java b/server/src/test/java/org/eclipse/openvsx/eclipse/EclipseServiceTest.java index bfc9771ec..0c381967e 100644 --- a/server/src/test/java/org/eclipse/openvsx/eclipse/EclipseServiceTest.java +++ b/server/src/test/java/org/eclipse/openvsx/eclipse/EclipseServiceTest.java @@ -52,6 +52,7 @@ import java.time.LocalDateTime; import java.util.List; import java.util.Map; +import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.fail; @@ -464,7 +465,8 @@ StorageUtilService storageUtilService( cache, entityManager, fileCacheDurationConfig, - cdnServiceConfig + cdnServiceConfig, + Optional.empty() ); } diff --git a/server/src/test/java/org/eclipse/openvsx/metrics/DownloadCountValidatorTest.java b/server/src/test/java/org/eclipse/openvsx/metrics/DownloadCountValidatorTest.java new file mode 100644 index 000000000..ccab251cb --- /dev/null +++ b/server/src/test/java/org/eclipse/openvsx/metrics/DownloadCountValidatorTest.java @@ -0,0 +1,136 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipse.openvsx.metrics; + +import org.eclipse.openvsx.metrics.config.DownloadCountValidationProperties; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.ValueOperations; + +import java.time.Duration; +import java.time.Instant; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class DownloadCountValidatorTest { + + private StringRedisTemplate redisTemplate; + private ValueOperations valueOps; + private DownloadCountValidationProperties properties; + private DownloadCountValidator validator; + + @BeforeEach + @SuppressWarnings("unchecked") + public void beforeEach() { + redisTemplate = Mockito.mock(StringRedisTemplate.class); + valueOps = Mockito.mock(ValueOperations.class); + Mockito.when(redisTemplate.opsForValue()).thenReturn(valueOps); + + properties = new DownloadCountValidationProperties(); + properties.setEnabled(true); + properties.setHourlyLimitPerIp(3); + properties.setKeyPrefix("download:dedup"); + properties.setLateArrivalHours(2); + properties.setAutomatedClientKeywords(List.of("curl", "bot")); + + validator = new DownloadCountValidator(redisTemplate, "getRemoteAddr()", properties); + } + + @Test + public void shouldSkipValidationWhenDisabled() { + properties.setEnabled(false); + + assertTrue(validator.shouldCountDownload(42L, "1.1.1.1", "curl/8.0", Instant.now())); + Mockito.verifyNoInteractions(valueOps); + } + + @Test + public void shouldRejectAutomatedUserAgents() { + assertFalse(validator.shouldCountDownload(42L, "1.1.1.1", "curl/8.0", Instant.now())); + Mockito.verifyNoInteractions(valueOps); + } + + @Test + public void shouldCountWhenUnderHourlyLimit() { + properties.setHourlyLimitPerIp(5); + properties.setLateArrivalHours(2); + properties.setKeyPrefix("custom:dedup"); + + // Simulate first download in this hour (count = 1, under limit of 5) + Mockito.when(valueOps.increment(Mockito.anyString())).thenReturn(1L); + + Instant eventTime = Instant.parse("2026-01-01T01:10:00Z"); + boolean result = validator.shouldCountDownload(99L, "10.10.10.10", "Mozilla/5.0", eventTime); + + assertTrue(result); + + // Key must include the custom prefix and extension ID + Duration expectedTtl = Duration.ofHours(1).plusHours(2); + Mockito.verify(valueOps).setIfAbsent( + Mockito.argThat(key -> key.startsWith("custom:dedup:") && key.contains(":99:")), + Mockito.eq("0"), + Mockito.eq(expectedTtl) + ); + } + + @Test + public void shouldNotCountWhenHourlyLimitExceeded() { + properties.setHourlyLimitPerIp(3); + + // Simulate 4th download — over the limit of 3 + Mockito.when(valueOps.increment(Mockito.anyString())).thenReturn(4L); + + boolean result = validator.shouldCountDownload(42L, "1.1.1.1", "Mozilla/5.0", Instant.now()); + + assertFalse(result); + } + + @Test + public void shouldNotCountWhenClientIpUnavailable() { + assertFalse(validator.shouldCountDownload(42L, null, "Mozilla/5.0", Instant.now())); + Mockito.verifyNoInteractions(valueOps); + } + + @Test + public void shouldUseEventTimeBucketInKey() { + properties.setLateArrivalHours(24); + + // Simulate first download in this hour (count = 1, under limit) + Mockito.when(valueOps.increment(Mockito.anyString())).thenReturn(1L); + + // 2026-01-01T00:00:00Z → epochSecond = 1735689600 → hourBucket = 482136 + Instant eventTime = Instant.parse("2026-01-01T00:00:00Z"); + boolean result = validator.shouldCountDownload(99L, "10.0.0.1", "Mozilla/5.0", eventTime); + + assertTrue(result); + + // TTL = 1 hour + 24 late-arrival hours = 25 hours + Duration expectedTtl = Duration.ofHours(1).plusHours(24); + Mockito.verify(valueOps).setIfAbsent( + Mockito.argThat(key -> key.startsWith("download:dedup:") && key.contains(":99:")), + Mockito.eq("0"), + Mockito.eq(expectedTtl) + ); + } + + @Test + public void shouldNotCountWhenEventTimeIsMissing() { + boolean result = validator.shouldCountDownload(99L, "10.0.0.1", "Mozilla/5.0", null); + assertFalse(result); + Mockito.verifyNoInteractions(valueOps); + } +} diff --git a/server/src/test/java/org/eclipse/openvsx/storage/StorageUtilServiceTest.java b/server/src/test/java/org/eclipse/openvsx/storage/StorageUtilServiceTest.java index f8ead9208..a7ec97d14 100644 --- a/server/src/test/java/org/eclipse/openvsx/storage/StorageUtilServiceTest.java +++ b/server/src/test/java/org/eclipse/openvsx/storage/StorageUtilServiceTest.java @@ -31,6 +31,7 @@ import java.util.Collections; import java.util.HashMap; import java.util.List; +import java.util.Optional; import static org.eclipse.openvsx.entities.FileResource.README; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -193,7 +194,8 @@ StorageUtilService storageUtilService( cache, entityManager, fileCacheDurationConfig, - cdnServiceConfig + cdnServiceConfig, + Optional.empty() ); } } diff --git a/server/src/test/java/org/eclipse/openvsx/storage/log/AwsDownloadCountHandlerTest.java b/server/src/test/java/org/eclipse/openvsx/storage/log/AwsDownloadCountHandlerTest.java new file mode 100644 index 000000000..44e9c95a8 --- /dev/null +++ b/server/src/test/java/org/eclipse/openvsx/storage/log/AwsDownloadCountHandlerTest.java @@ -0,0 +1,72 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipse.openvsx.storage.log; + +import org.eclipse.openvsx.entities.FileResource; +import org.eclipse.openvsx.metrics.DownloadCountValidator; +import org.eclipse.openvsx.storage.AwsStorageService; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +public class AwsDownloadCountHandlerTest { + + @Test + public void shouldFilterEventsWhenValidatorRejectsSome() { + var processor = Mockito.mock(DownloadCountProcessor.class); + var awsStorage = Mockito.mock(AwsStorageService.class); + var validator = Mockito.mock(DownloadCountValidator.class); + + Mockito.when(processor.resolveDownloadFileToExtensionId(Mockito.eq(FileResource.STORAGE_AWS), Mockito.anyList())) + .thenReturn(Map.of("TEST.VSIX", 10L)); + Instant ts = Instant.parse("2026-01-01T00:10:00Z"); + Mockito.when(validator.shouldCountDownload(10L, "1.1.1.1", "Mozilla/5.0", ts)) + .thenReturn(true); + Mockito.when(validator.shouldCountDownload(10L, "2.2.2.2", "curl/8.0", ts)) + .thenReturn(false); + + var handler = new AwsDownloadCountHandler(awsStorage, processor, Optional.of(validator)); + var events = List.of( + new AwsDownloadCountHandler.DownloadEvent("TEST.VSIX", "1.1.1.1", "Mozilla/5.0", ts), + new AwsDownloadCountHandler.DownloadEvent("TEST.VSIX", "2.2.2.2", "curl/8.0", ts) + ); + + var counts = handler.countValidatedEvents(events); + + assertEquals(Map.of("TEST.VSIX", 1), counts); + } + + @Test + public void shouldCountAllEventsWhenValidatorIsMissing() { + var processor = Mockito.mock(DownloadCountProcessor.class); + var awsStorage = Mockito.mock(AwsStorageService.class); + Mockito.when(processor.resolveDownloadFileToExtensionId(Mockito.eq(FileResource.STORAGE_AWS), Mockito.anyList())) + .thenReturn(Map.of("TEST.VSIX", 10L)); + + var handler = new AwsDownloadCountHandler(awsStorage, processor, Optional.empty()); + var events = List.of( + new AwsDownloadCountHandler.DownloadEvent("TEST.VSIX", "1.1.1.1", "Mozilla/5.0", null), + new AwsDownloadCountHandler.DownloadEvent("TEST.VSIX", "2.2.2.2", "curl/8.0", null) + ); + + var counts = handler.countValidatedEvents(events); + + assertEquals(Map.of("TEST.VSIX", 2), counts); + } +} diff --git a/server/src/test/java/org/eclipse/openvsx/storage/log/AzureDownloadCountHandlerTest.java b/server/src/test/java/org/eclipse/openvsx/storage/log/AzureDownloadCountHandlerTest.java new file mode 100644 index 000000000..9d283ca6d --- /dev/null +++ b/server/src/test/java/org/eclipse/openvsx/storage/log/AzureDownloadCountHandlerTest.java @@ -0,0 +1,51 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipse.openvsx.storage.log; + +import org.eclipse.openvsx.entities.FileResource; +import org.eclipse.openvsx.metrics.DownloadCountValidator; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +public class AzureDownloadCountHandlerTest { + + @Test + public void shouldFilterEventsWhenValidatorRejectsSome() { + var processor = Mockito.mock(DownloadCountProcessor.class); + var validator = Mockito.mock(DownloadCountValidator.class); + Mockito.when(processor.resolveDownloadFileToExtensionId(Mockito.eq(FileResource.STORAGE_AZURE), Mockito.anyList())) + .thenReturn(Map.of("TEST.VSIX", 10L)); + Instant ts = Instant.parse("2026-01-01T00:10:00Z"); + Mockito.when(validator.shouldCountDownload(10L, "1.1.1.1", "Mozilla/5.0", ts)) + .thenReturn(true); + Mockito.when(validator.shouldCountDownload(10L, "2.2.2.2", "curl/8.0", ts)) + .thenReturn(false); + + var handler = new AzureDownloadCountHandler(processor, Optional.of(validator)); + var events = List.of( + new AzureDownloadCountHandler.DownloadEvent("TEST.VSIX", "1.1.1.1", "Mozilla/5.0", ts), + new AzureDownloadCountHandler.DownloadEvent("TEST.VSIX", "2.2.2.2", "curl/8.0", ts) + ); + + var counts = handler.countValidatedEvents(events); + + assertEquals(Map.of("TEST.VSIX", 1), counts); + } +} diff --git a/server/src/test/java/org/eclipse/openvsx/storage/log/CloudFrontLogFileParserTest.java b/server/src/test/java/org/eclipse/openvsx/storage/log/CloudFrontLogFileParserTest.java index 2f7d7fce3..1031d09f0 100644 --- a/server/src/test/java/org/eclipse/openvsx/storage/log/CloudFrontLogFileParserTest.java +++ b/server/src/test/java/org/eclipse/openvsx/storage/log/CloudFrontLogFileParserTest.java @@ -17,6 +17,7 @@ import java.io.BufferedReader; import java.io.IOException; import java.io.InputStreamReader; +import java.time.Instant; import static org.junit.jupiter.api.Assertions.*; @@ -39,6 +40,10 @@ record = parser.parse(reader.readLine()); assertEquals("OPTIONS", record.method()); assertEquals(200, record.status()); assertEquals("/vscjava/vscode-java-pack/0.30.4/package.json", record.url()); + assertEquals("1.1.1.1", record.clientIp()); + assertEquals("Mozilla/5.0", record.userAgent()); + // Date "2025-12-03" + time "13:17:20" UTC → verify event-time is parsed correctly + assertEquals(Instant.parse("2025-12-03T13:17:20Z"), record.eventTime()); } } } diff --git a/server/src/test/java/org/eclipse/openvsx/storage/log/FastlyLogFileParserTest.java b/server/src/test/java/org/eclipse/openvsx/storage/log/FastlyLogFileParserTest.java index b29f03ee9..6c4349c19 100644 --- a/server/src/test/java/org/eclipse/openvsx/storage/log/FastlyLogFileParserTest.java +++ b/server/src/test/java/org/eclipse/openvsx/storage/log/FastlyLogFileParserTest.java @@ -17,6 +17,7 @@ import java.io.BufferedReader; import java.io.IOException; import java.io.InputStreamReader; +import java.time.Instant; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; @@ -35,6 +36,10 @@ var record = parser.parse(reader.readLine()); assertEquals("GET", record.method()); assertEquals(301, record.status()); assertEquals("/favicon.ico", record.url()); + assertEquals("1.1.1.1", record.clientIp()); + assertEquals("Mozilla/5.0", record.userAgent()); + // "2026-02-09T04:20:50+0000" → UTC Instant + assertEquals(Instant.parse("2026-02-09T04:20:50Z"), record.eventTime()); } } }