Merge branch 'refs/heads/fix/inbox-timestamp-exception' into sattelgeschichten
This commit is contained in:
commit
29538575b2
6 changed files with 320 additions and 5 deletions
|
|
@ -29,6 +29,7 @@ import java.net.URI;
|
|||
import java.net.URISyntaxException;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.*;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
|
|
@ -436,7 +437,7 @@ public class ActivityPubController {
|
|||
noteObject.put("id", activityUri);
|
||||
noteObject.put("type", "Note");
|
||||
noteObject.put("attributedTo", actorUri);
|
||||
noteObject.put("published", activity.getCreatedAt().toString());
|
||||
noteObject.put("published", activity.getCreatedAt().atOffset(ZoneOffset.UTC).toInstant().toString());
|
||||
noteObject.put("content", formatActivityContent(activity));
|
||||
noteObject.put("url", activityUri);
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import org.springframework.beans.factory.annotation.Value;
|
|||
import org.springframework.scheduling.annotation.Async;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
|
@ -199,7 +200,7 @@ public class ActivityPostProcessingService {
|
|||
noteObject.put("id", activityUri);
|
||||
noteObject.put("type", "Note");
|
||||
noteObject.put("attributedTo", actorUri);
|
||||
noteObject.put("published", activity.getCreatedAt().toString());
|
||||
noteObject.put("published", activity.getCreatedAt().atOffset(ZoneOffset.UTC).toInstant().toString());
|
||||
noteObject.put("content", formatActivityContent(activity));
|
||||
noteObject.put("url", baseUrl + "/activities/" + activity.getId());
|
||||
|
||||
|
|
|
|||
|
|
@ -21,6 +21,11 @@ import org.springframework.stereotype.Service;
|
|||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.time.ZonedDateTime;
|
||||
import java.time.format.DateTimeParseException;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
|
|
@ -411,7 +416,7 @@ public class InboxProcessor {
|
|||
|
||||
// Parse published timestamp
|
||||
String publishedStr = (String) noteObject.get("published");
|
||||
Instant publishedAt = publishedStr != null ? Instant.parse(publishedStr) : Instant.now();
|
||||
Instant publishedAt = parsePublishedAt(publishedStr);
|
||||
|
||||
// Build RemoteActivity entity
|
||||
RemoteActivity remoteActivity = RemoteActivity.builder()
|
||||
|
|
@ -824,6 +829,44 @@ public class InboxProcessor {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse ActivityPub published timestamps.
|
||||
*
|
||||
* <p>Preferred input is a full ISO-8601 instant with timezone/offset. Some
|
||||
* remote implementations still send zoneless timestamps, so we accept those
|
||||
* as a compatibility fallback and interpret them as UTC.
|
||||
*/
|
||||
private Instant parsePublishedAt(String publishedStr) {
|
||||
if (publishedStr == null || publishedStr.isBlank()) {
|
||||
return Instant.now();
|
||||
}
|
||||
|
||||
try {
|
||||
return Instant.parse(publishedStr);
|
||||
} catch (DateTimeParseException ignored) {
|
||||
// Fall through to compatibility parsers below.
|
||||
}
|
||||
|
||||
try {
|
||||
return OffsetDateTime.parse(publishedStr).toInstant();
|
||||
} catch (DateTimeParseException ignored) {
|
||||
// Fall through to compatibility parsers below.
|
||||
}
|
||||
|
||||
try {
|
||||
return ZonedDateTime.parse(publishedStr).toInstant();
|
||||
} catch (DateTimeParseException ignored) {
|
||||
// Fall through to compatibility parsers below.
|
||||
}
|
||||
|
||||
try {
|
||||
return LocalDateTime.parse(publishedStr).atOffset(ZoneOffset.UTC).toInstant();
|
||||
} catch (DateTimeParseException e) {
|
||||
log.warn("Failed to parse published timestamp: {}", publishedStr, e);
|
||||
return Instant.now();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize object to JSON string.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -0,0 +1,114 @@
|
|||
package net.javahippie.fitpub.controller;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import net.javahippie.fitpub.model.entity.Activity;
|
||||
import net.javahippie.fitpub.model.entity.User;
|
||||
import net.javahippie.fitpub.repository.ActivityRepository;
|
||||
import net.javahippie.fitpub.repository.FollowRepository;
|
||||
import net.javahippie.fitpub.repository.UserRepository;
|
||||
import net.javahippie.fitpub.security.HttpSignatureValidator;
|
||||
import net.javahippie.fitpub.service.ActivityImageService;
|
||||
import net.javahippie.fitpub.service.FederationService;
|
||||
import net.javahippie.fitpub.service.InboxProcessor;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.io.File;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@DisplayName("ActivityPubController Tests")
|
||||
class ActivityPubControllerTest {
|
||||
|
||||
@Mock
|
||||
private UserRepository userRepository;
|
||||
|
||||
@Mock
|
||||
private ActivityRepository activityRepository;
|
||||
|
||||
@Mock
|
||||
private ActivityImageService activityImageService;
|
||||
|
||||
@Mock
|
||||
private InboxProcessor inboxProcessor;
|
||||
|
||||
@Mock
|
||||
private FollowRepository followRepository;
|
||||
|
||||
@Mock
|
||||
private HttpSignatureValidator signatureValidator;
|
||||
|
||||
@Mock
|
||||
private FederationService federationService;
|
||||
|
||||
@Mock
|
||||
private ObjectMapper objectMapper;
|
||||
|
||||
@InjectMocks
|
||||
private ActivityPubController controller;
|
||||
|
||||
private UUID activityId;
|
||||
private UUID userId;
|
||||
private Activity activity;
|
||||
private User user;
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
activityId = UUID.randomUUID();
|
||||
userId = UUID.randomUUID();
|
||||
createdAt = LocalDateTime.of(2026, 5, 2, 9, 24, 50, 921_241_000);
|
||||
|
||||
ReflectionTestUtils.setField(controller, "baseUrl", "https://fitpub.example");
|
||||
|
||||
activity = Activity.builder()
|
||||
.id(activityId)
|
||||
.userId(userId)
|
||||
.activityType(Activity.ActivityType.RUN)
|
||||
.title("Lunch Run")
|
||||
.description("Sunny run")
|
||||
.visibility(Activity.Visibility.PUBLIC)
|
||||
.totalDistance(BigDecimal.valueOf(5000))
|
||||
.totalDurationSeconds(1800L)
|
||||
.createdAt(createdAt)
|
||||
.build();
|
||||
|
||||
user = User.builder()
|
||||
.id(userId)
|
||||
.username("JaneDoe")
|
||||
.email("janedoe@example.com")
|
||||
.publicKey("public-key")
|
||||
.privateKey("private-key")
|
||||
.build();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should serialize activity published timestamp with timezone")
|
||||
void getActivity_ShouldSerializePublishedTimestampWithTimezone() {
|
||||
when(activityRepository.findById(activityId)).thenReturn(Optional.of(activity));
|
||||
when(userRepository.findById(userId)).thenReturn(Optional.of(user));
|
||||
when(activityImageService.getActivityImageFile(activityId)).thenReturn(new File("/definitely/nonexistent-fitpub-test-image"));
|
||||
|
||||
ResponseEntity<Map<String, Object>> response = controller.getActivity(activityId);
|
||||
|
||||
assertThat(response.getStatusCode().is2xxSuccessful()).isTrue();
|
||||
assertThat(response.getBody()).isNotNull();
|
||||
assertThat(response.getBody().get("published"))
|
||||
.isEqualTo(createdAt.atOffset(ZoneOffset.UTC).toInstant().toString());
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import org.junit.jupiter.api.BeforeEach;
|
|||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
|
@ -15,9 +16,11 @@ import org.springframework.test.util.ReflectionTestUtils;
|
|||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
|
|
@ -56,11 +59,13 @@ class ActivityPostProcessingServiceTest {
|
|||
private UUID userId;
|
||||
private Activity testActivity;
|
||||
private User testUser;
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
activityId = UUID.randomUUID();
|
||||
userId = UUID.randomUUID();
|
||||
createdAt = LocalDateTime.of(2026, 5, 2, 9, 24, 50, 921_241_000);
|
||||
|
||||
// Set baseUrl via reflection (since it's @Value injected)
|
||||
ReflectionTestUtils.setField(service, "baseUrl", "https://test.example");
|
||||
|
|
@ -76,8 +81,8 @@ class ActivityPostProcessingServiceTest {
|
|||
.totalDistance(BigDecimal.valueOf(5000))
|
||||
.totalDurationSeconds(1800L)
|
||||
.elevationGain(BigDecimal.valueOf(100))
|
||||
.startedAt(LocalDateTime.now())
|
||||
.createdAt(LocalDateTime.now())
|
||||
.startedAt(createdAt.minusMinutes(30))
|
||||
.createdAt(createdAt)
|
||||
.build();
|
||||
|
||||
// Create test user
|
||||
|
|
@ -232,6 +237,24 @@ class ActivityPostProcessingServiceTest {
|
|||
verify(federationService).sendCreateActivity(anyString(), any(), eq(testUser), eq(false));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should serialize federation note published timestamp with timezone")
|
||||
void testPublishToFederationAsync_PublishedTimestampIncludesTimezone() {
|
||||
when(activityRepository.findById(activityId)).thenReturn(Optional.of(testActivity));
|
||||
when(userRepository.findById(userId)).thenReturn(Optional.of(testUser));
|
||||
when(activityImageService.generateActivityImage(testActivity)).thenReturn(null);
|
||||
doNothing().when(federationService).sendCreateActivity(anyString(), any(), any(), anyBoolean());
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
ArgumentCaptor<java.util.Map<String, Object>> noteCaptor = ArgumentCaptor.forClass(java.util.Map.class);
|
||||
|
||||
service.publishToFederationAsync(activityId, userId);
|
||||
|
||||
verify(federationService).sendCreateActivity(anyString(), noteCaptor.capture(), eq(testUser), eq(true));
|
||||
assertThat(noteCaptor.getValue().get("published"))
|
||||
.isEqualTo(createdAt.atOffset(ZoneOffset.UTC).toInstant().toString());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should skip federation for PRIVATE activity")
|
||||
void testPublishToFederationAsync_PrivateActivity() {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,133 @@
|
|||
package net.javahippie.fitpub.service;
|
||||
|
||||
import net.javahippie.fitpub.model.entity.Follow;
|
||||
import net.javahippie.fitpub.model.entity.RemoteActor;
|
||||
import net.javahippie.fitpub.model.entity.User;
|
||||
import net.javahippie.fitpub.repository.ActivityRepository;
|
||||
import net.javahippie.fitpub.repository.CommentRepository;
|
||||
import net.javahippie.fitpub.repository.FollowRepository;
|
||||
import net.javahippie.fitpub.repository.LikeRepository;
|
||||
import net.javahippie.fitpub.repository.RemoteActivityRepository;
|
||||
import net.javahippie.fitpub.repository.RemoteActorRepository;
|
||||
import net.javahippie.fitpub.repository.UserRepository;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@DisplayName("InboxProcessor Tests")
|
||||
class InboxProcessorTest {
|
||||
|
||||
@Mock
|
||||
private UserRepository userRepository;
|
||||
|
||||
@Mock
|
||||
private FollowRepository followRepository;
|
||||
|
||||
@Mock
|
||||
private FederationService federationService;
|
||||
|
||||
@Mock
|
||||
private ActivityRepository activityRepository;
|
||||
|
||||
@Mock
|
||||
private LikeRepository likeRepository;
|
||||
|
||||
@Mock
|
||||
private CommentRepository commentRepository;
|
||||
|
||||
@Mock
|
||||
private NotificationService notificationService;
|
||||
|
||||
@Mock
|
||||
private RemoteActivityRepository remoteActivityRepository;
|
||||
|
||||
@Mock
|
||||
private RemoteActorRepository remoteActorRepository;
|
||||
|
||||
@InjectMocks
|
||||
private InboxProcessor inboxProcessor;
|
||||
|
||||
private User localUser;
|
||||
private String remoteActorUri;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
localUser = User.builder()
|
||||
.id(UUID.randomUUID())
|
||||
.username("JaneDoe")
|
||||
.email("janedoe@example.com")
|
||||
.passwordHash("irrelevant")
|
||||
.publicKey("public-key")
|
||||
.privateKey("private-key")
|
||||
.build();
|
||||
|
||||
remoteActorUri = "https://fitpub.example.com/users/JohnDoe";
|
||||
|
||||
ReflectionTestUtils.setField(inboxProcessor, "baseUrl", "https://fitpub.example");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should persist remote activity when published timestamp has fractional seconds but no timezone")
|
||||
void processCreateRemoteActivity_WithPublishedTimestampWithoutTimezone_ShouldPersistRemoteActivity() {
|
||||
when(remoteActivityRepository.existsByActivityUri("https://fitpub.example.com/activities/123"))
|
||||
.thenReturn(false);
|
||||
when(federationService.fetchRemoteActor(remoteActorUri)).thenReturn(RemoteActor.builder()
|
||||
.actorUri(remoteActorUri)
|
||||
.username("JohnDoe")
|
||||
.domain("fitpub.example.com")
|
||||
.inboxUrl("https://fitpub.example.com/users/JohnDoe/inbox")
|
||||
.publicKey("public-key")
|
||||
.build());
|
||||
when(userRepository.findByUsername("JaneDoe")).thenReturn(Optional.of(localUser));
|
||||
when(followRepository.findByFollowerIdAndFollowingActorUri(localUser.getId(), remoteActorUri))
|
||||
.thenReturn(Optional.of(Follow.builder()
|
||||
.followerId(localUser.getId())
|
||||
.followingActorUri(remoteActorUri)
|
||||
.status(Follow.FollowStatus.ACCEPTED)
|
||||
.build()));
|
||||
|
||||
Map<String, Object> note = Map.of(
|
||||
"id", "https://fitpub.example.com/activities/123",
|
||||
"type", "Note",
|
||||
"name", "Lunch Run",
|
||||
"content", "<p>Sunny run</p>",
|
||||
"published", "2026-05-02T09:24:50.921241",
|
||||
"to", List.of("https://www.w3.org/ns/activitystreams#Public")
|
||||
);
|
||||
|
||||
Map<String, Object> activity = Map.of(
|
||||
"type", "Create",
|
||||
"actor", remoteActorUri,
|
||||
"object", note
|
||||
);
|
||||
|
||||
ArgumentCaptor<net.javahippie.fitpub.model.entity.RemoteActivity> remoteActivityCaptor =
|
||||
ArgumentCaptor.forClass(net.javahippie.fitpub.model.entity.RemoteActivity.class);
|
||||
|
||||
inboxProcessor.processActivity("JaneDoe", activity);
|
||||
|
||||
verify(remoteActivityRepository).existsByActivityUri("https://fitpub.example.com/activities/123");
|
||||
verify(federationService).fetchRemoteActor(remoteActorUri);
|
||||
verify(remoteActivityRepository).save(remoteActivityCaptor.capture());
|
||||
|
||||
assertThat(remoteActivityCaptor.getValue().getPublishedAt())
|
||||
.isEqualTo(Instant.parse("2026-05-02T09:24:50.921241Z"));
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue