Fixes an ActivityPub timestamp issue in federation. #42

Closed
McPringle wants to merge 5 commits from fix/inbox-timestamp-exception into main
7 changed files with 475 additions and 5 deletions

View file

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

View file

@ -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());

View file

@ -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.
*/

View file

@ -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());
}
}

View file

@ -2,19 +2,25 @@ package net.javahippie.fitpub.integration;
import com.fasterxml.jackson.databind.ObjectMapper;
import net.javahippie.fitpub.config.TestcontainersConfiguration;
import net.javahippie.fitpub.model.entity.Activity;
import net.javahippie.fitpub.service.ActivityImageService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import net.javahippie.fitpub.model.entity.Follow;
import net.javahippie.fitpub.model.entity.RemoteActor;
import net.javahippie.fitpub.model.entity.RemoteActivity;
import net.javahippie.fitpub.model.entity.User;
import net.javahippie.fitpub.repository.ActivityRepository;
import net.javahippie.fitpub.repository.FollowRepository;
import net.javahippie.fitpub.repository.RemoteActivityRepository;
import net.javahippie.fitpub.repository.RemoteActorRepository;
import net.javahippie.fitpub.repository.UserRepository;
import net.javahippie.fitpub.security.HttpSignatureValidator;
import net.javahippie.fitpub.security.JwtTokenProvider;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
@ -26,15 +32,21 @@ import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import org.springframework.transaction.annotation.Transactional;
import java.io.File;
import java.math.BigDecimal;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.NoSuchAlgorithmException;
import java.time.Instant;
import java.time.LocalDateTime;
import java.util.Base64;
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.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@ -63,6 +75,12 @@ class FederationFollowFlowIntegrationTest {
@Autowired
private RemoteActorRepository remoteActorRepository;
@Autowired
private RemoteActivityRepository remoteActivityRepository;
@Autowired
private ActivityRepository activityRepository;
@Autowired
private PasswordEncoder passwordEncoder;
@ -72,6 +90,9 @@ class FederationFollowFlowIntegrationTest {
@Autowired
private HttpSignatureValidator signatureValidator;
@MockBean
private ActivityImageService activityImageService;
@Value("${fitpub.base-url}")
private String baseUrl;
@ -101,6 +122,22 @@ class FederationFollowFlowIntegrationTest {
authToken = jwtTokenProvider.createToken(testUser.getUsername());
}
private User createFederatedUser(String username, String email, String displayName) throws NoSuchAlgorithmException {
KeyPair keyPair = generateRsaKeyPair();
String publicKey = encodePublicKey(keyPair.getPublic().getEncoded());
String privateKey = encodePrivateKey(keyPair.getPrivate().getEncoded());
return userRepository.save(User.builder()
.username(username)
.email(email)
.passwordHash(passwordEncoder.encode("password123"))
.displayName(displayName)
.publicKey(publicKey)
.privateKey(privateKey)
.enabled(true)
.build());
}
private KeyPair generateRsaKeyPair() throws NoSuchAlgorithmException {
KeyPairGenerator keyGen = KeyPairGenerator.getInstance("RSA");
keyGen.initialize(2048);
@ -270,6 +307,107 @@ class FederationFollowFlowIntegrationTest {
assertThat(updatedFollow.getStatus()).isEqualTo(Follow.FollowStatus.ACCEPTED);
}
@Test
@DisplayName("Should import its own exported public activity through inbox")
void testActivityRoundtripThroughExportAndInbox() throws Exception {
User importingUser = testUser;
User exportingUser = createFederatedUser("janedoe", "janedoe@example.com", "Jane Doe");
Activity activity = activityRepository.save(Activity.builder()
.userId(exportingUser.getId())
.activityType(Activity.ActivityType.RUN)
.title("Lunch Run")
.description("Sunny run in the city")
.startedAt(LocalDateTime.of(2026, 5, 2, 12, 0))
.endedAt(LocalDateTime.of(2026, 5, 2, 12, 30))
.createdAt(LocalDateTime.of(2026, 5, 2, 12, 31, 45, 123_000_000))
.visibility(Activity.Visibility.PUBLIC)
.totalDistance(BigDecimal.valueOf(5000))
.totalDurationSeconds(1800L)
.elevationGain(BigDecimal.valueOf(100))
.sourceFileFormat("FIT")
.published(true)
.build());
String exportingActorUri = baseUrl + "/users/" + exportingUser.getUsername();
when(activityImageService.getActivityImageFile(activity.getId()))
.thenReturn(new File("/definitely/nonexistent-fitpub-roundtrip-image"));
remoteActorRepository.save(RemoteActor.builder()
.actorUri(exportingActorUri)
.username(exportingUser.getUsername())
.domain(java.net.URI.create(baseUrl).getHost())
.displayName(exportingUser.getDisplayName())
.inboxUrl(exportingActorUri + "/inbox")
.outboxUrl(exportingActorUri + "/outbox")
.publicKey(exportingUser.getPublicKey())
.publicKeyId(exportingActorUri + "#main-key")
.lastFetchedAt(Instant.now())
.build());
followRepository.save(Follow.builder()
.followerId(importingUser.getId())
.followingActorUri(exportingActorUri)
.status(Follow.FollowStatus.ACCEPTED)
.activityId(baseUrl + "/activities/follow/" + UUID.randomUUID())
.build());
MvcResult exportResult = mockMvc.perform(get("/activities/" + activity.getId())
.accept("application/activity+json"))
.andExpect(status().isOk())
.andReturn();
@SuppressWarnings("unchecked")
Map<String, Object> exportedNote = objectMapper.readValue(exportResult.getResponse().getContentAsByteArray(), Map.class);
Map<String, Object> createActivity = Map.of(
"@context", "https://www.w3.org/ns/activitystreams",
"type", "Create",
"id", baseUrl + "/activities/create/" + UUID.randomUUID(),
"actor", exportingActorUri,
"object", exportedNote
);
String privateKeyPem = exportingUser.getPrivateKey();
String inboxPath = "/users/" + importingUser.getUsername() + "/inbox";
String inboxUrl = "http://localhost" + inboxPath;
String body = objectMapper.writeValueAsString(createActivity);
HttpSignatureValidator.SignatureHeaders sigHeaders = signatureValidator.signRequest(
"POST", inboxUrl, body, privateKeyPem, exportingActorUri + "#main-key"
);
mockMvc.perform(post(inboxPath)
.contentType("application/activity+json")
.header("Host", sigHeaders.host)
.header("Date", sigHeaders.date)
.header("Digest", sigHeaders.digest)
.header("Signature", sigHeaders.signature)
.content(body))
.andExpect(status().isAccepted());
RemoteActivity imported = remoteActivityRepository.findByActivityUri((String) exportedNote.get("id"))
.orElseThrow();
assertThat(imported.getActivityUri()).isEqualTo(exportedNote.get("id"));
assertThat(imported.getRemoteActorUri()).isEqualTo(exportingActorUri);
assertThat(imported.getTitle()).isEqualTo(exportedNote.getOrDefault("name",
exportedNote.getOrDefault("summary", "Untitled Activity")));
assertThat(imported.getDescription()).isEqualTo(stripHtml((String) exportedNote.get("content")));
assertThat(imported.getPublishedAt()).isEqualTo(Instant.parse((String) exportedNote.get("published")));
assertThat(imported.getVisibility()).isEqualTo(RemoteActivity.Visibility.PUBLIC);
assertThat(imported.getActivityType()).isNull();
assertThat(imported.getTotalDistance()).isNull();
assertThat(imported.getTotalDurationSeconds()).isNull();
assertThat(imported.getElevationGain()).isNull();
assertThat(imported.getAveragePaceSeconds()).isNull();
assertThat(imported.getAverageHeartRate()).isNull();
assertThat(imported.getMaxSpeed()).isNull();
assertThat(imported.getAverageSpeed()).isNull();
assertThat(imported.getCalories()).isNull();
assertThat(imported.getMapImageUrl()).isNull();
assertThat(imported.getTrackGeojsonUrl()).isNull();
}
@Test
@DisplayName("Should reject inbox POST without HTTP signature with 401")
void testInboxRejectsUnsignedRequest() throws Exception {
@ -310,6 +448,23 @@ class FederationFollowFlowIntegrationTest {
.andExpect(status().isUnauthorized());
}
private String stripHtml(String html) {
if (html == null) {
return "";
}
return html
.replaceAll("<br\\s*/?>", "\n")
.replaceAll("<p>", "")
.replaceAll("</p>", "\n")
.replaceAll("<[^>]+>", "")
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&quot;", "\"")
.replace("&#39;", "'")
.replace("&amp;", "&")
.trim();
}
@Test
@DisplayName("Should process Undo Follow activity and remove follow relationship")
void testProcessUndoFollowActivity() throws Exception {

View file

@ -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() {

View file

@ -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"));
}
}