Skip to content

Commit 45838bb

Browse files
VamshiRajRjNagula Vamshi Raj
andauthored
Fixed the url checking and wrote some integration tests (#171)
1. Added logic in SearchController to detect and process remote user queries (e.g. @user@instance.com). 2. Implemented validation to ensure remote domains are well-formed before making WebClient requests. --------- Co-authored-by: Nagula Vamshi Raj <nagulavamshiraj@Nagulas-MacBook-Pro.local>
1 parent 1edfab2 commit 45838bb

File tree

7 files changed

+223
-17
lines changed

7 files changed

+223
-17
lines changed

.DS_Store

-6 KB
Binary file not shown.

.gitignore

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,5 @@
33
.idea/misc.xml
44
.idea/uiDesigner.xml
55
.idea/google-java-format.xml
6-
client/.DS_Store
7-
util/.DS_Store
8-
.DS_Store
6+
# Ignore .DS_Store files everywhere
7+
**/.DS_Store

.idea/google-java-format.xml

Lines changed: 0 additions & 6 deletions
This file was deleted.

client/.DS_Store

-6 KB
Binary file not shown.

server/src/main/java/edu/sjsu/moth/server/controller/SearchController.java

Lines changed: 55 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
package edu.sjsu.moth.server.controller;
22

33
import edu.sjsu.moth.generated.SearchResult;
4+
import edu.sjsu.moth.server.db.Account;
5+
import edu.sjsu.moth.server.db.AccountRepository;
6+
import edu.sjsu.moth.server.db.ExternalActorRepository;
47
import edu.sjsu.moth.server.service.AccountService;
58
import edu.sjsu.moth.server.service.StatusService;
69
import org.springframework.beans.factory.annotation.Autowired;
@@ -13,6 +16,7 @@
1316
import reactor.core.publisher.Mono;
1417

1518
import java.security.Principal;
19+
import java.util.List;
1620
import java.util.Optional;
1721

1822
@RestController
@@ -24,6 +28,9 @@ public class SearchController {
2428
@Autowired
2529
AccountService accountService;
2630

31+
@Autowired
32+
AccountRepository accountRepository;
33+
2734
@Autowired
2835
public SearchController(WebClient.Builder webBuilder) {
2936
}
@@ -55,18 +62,59 @@ public Mono<SearchResult> doSearch(@RequestParam("q") String query, Principal us
5562
if (query.startsWith("@")) // truncate leading @ if its above, transform into user@someInstance.com
5663
query = query.substring(1);
5764
String[] splitQuery = query.split("@", 2); // split into 2 parts, split at @ char
58-
String domain = "https://" + splitQuery[1] + "/api/v2/search"; // use as domain below
59-
if (splitQuery.length == 2) {
65+
if (splitQuery.length == 2 && !splitQuery[0].isEmpty() && !splitQuery[1].isEmpty()) {
66+
String domain = splitQuery[1].trim();
67+
68+
// Optional: Validate domain (basic check)
69+
if (!domain.matches("^[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$")) {
70+
return Mono.empty();
71+
}
72+
73+
String baseUrl = "https://" + domain + "/api/v2/search";
6074
Integer finalLimit = limit; // necessary as local var ref from lambda must be final
61-
return WebClient.builder().baseUrl(domain).build().get()
62-
.uri(uriBuilder -> uriBuilder.queryParam("q", splitQuery[0]).queryParam("limit", finalLimit)
63-
.queryParamIfPresent("type", Optional.ofNullable(type)).build())
64-
.accept(MediaType.APPLICATION_JSON).retrieve().bodyToMono(SearchResult.class)
75+
return WebClient.builder()
76+
.baseUrl(baseUrl)
77+
.build()
78+
.get()
79+
.uri(uriBuilder -> uriBuilder
80+
.queryParam("q", splitQuery[0])
81+
.queryParam("limit", finalLimit)
82+
.queryParamIfPresent("type", Optional.ofNullable(type))
83+
.build())
84+
.accept(MediaType.APPLICATION_JSON)
85+
.retrieve()
86+
.bodyToMono(SearchResult.class)
87+
.flatMap(searchResult -> {
88+
if (searchResult.accounts == null || searchResult.accounts.isEmpty()) {
89+
return Mono.just(searchResult);
90+
}
91+
// Filter out local users and normalize remote accts
92+
List<Account> remoteAccounts = searchResult.accounts.stream()
93+
.filter(account -> {
94+
// If the URL does not point to your own domain, it's remote
95+
return account.url != null && !account.url.contains(MothController.HOSTNAME);
96+
})
97+
.peek(account -> {
98+
// If acct does not contain "@", normalize it with the remote domain
99+
if (account.acct != null && !account.acct.contains("@") && account.url != null) {
100+
String remoteDomain = account.url.split("/")[2]; // e.g., mastodon.social
101+
account.acct = account.acct + "@" + remoteDomain;
102+
}
103+
})
104+
.toList();
105+
106+
if (remoteAccounts.isEmpty()) {
107+
return Mono.just(searchResult);
108+
}
109+
110+
return accountRepository.saveAll(remoteAccounts)
111+
.then(Mono.just(searchResult));
112+
})
65113
.onErrorResume(WebClientException.class, e -> {
66114
System.err.println("Error: " + e.getMessage());
67-
e.printStackTrace();
68115
return Mono.empty();
69116
});
117+
70118
}
71119
} else {
72120
// normal search (of local instance)
@@ -100,5 +148,4 @@ public Mono<SearchResult> doSearch(@RequestParam("q") String query, Principal us
100148
}
101149
return Mono.empty();
102150
}
103-
104151
}
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
package edu.sjsu.moth.controllers;
2+
3+
import de.flapdoodle.embed.mongo.config.Net;
4+
import de.flapdoodle.embed.mongo.distribution.Version;
5+
import de.flapdoodle.embed.mongo.transitions.Mongod;
6+
import de.flapdoodle.embed.mongo.transitions.RunningMongodProcess;
7+
import de.flapdoodle.embed.process.io.ProcessOutput;
8+
import de.flapdoodle.reverse.TransitionWalker;
9+
import de.flapdoodle.reverse.transitions.Start;
10+
import edu.sjsu.moth.IntegrationTest;
11+
import edu.sjsu.moth.server.MothServerMain;
12+
import edu.sjsu.moth.server.db.Account;
13+
import edu.sjsu.moth.server.db.AccountRepository;
14+
import edu.sjsu.moth.server.util.MothConfiguration;
15+
import org.junit.jupiter.api.AfterAll;
16+
import org.junit.jupiter.api.Assertions;
17+
import org.junit.jupiter.api.BeforeAll;
18+
import org.junit.jupiter.api.Test;
19+
import org.springframework.beans.factory.annotation.Autowired;
20+
import org.springframework.boot.test.autoconfigure.data.mongo.AutoConfigureDataMongo;
21+
import org.springframework.boot.test.autoconfigure.web.reactive.AutoConfigureWebTestClient;
22+
import org.springframework.boot.test.context.SpringBootTest;
23+
import org.springframework.context.annotation.ComponentScan;
24+
import org.springframework.http.MediaType;
25+
import org.springframework.test.web.reactive.server.WebTestClient;
26+
27+
import java.io.File;
28+
import java.util.Random;
29+
30+
import static org.springframework.security.test.web.reactive.server.SecurityMockServerConfigurers.mockJwt;
31+
32+
@SpringBootTest(classes = { SearchControllerTest.class }, webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
33+
@AutoConfigureDataMongo
34+
@ComponentScan(basePackageClasses = MothServerMain.class)
35+
@AutoConfigureWebTestClient
36+
public class SearchControllerTest {
37+
38+
public static final String SEARCH_ENDPOINT = "/api/v2/search";
39+
static private final int RAND_MONGO_PORT = 27017 + new Random().nextInt(17, 37);
40+
static private TransitionWalker.ReachedState<RunningMongodProcess> eMongod;
41+
42+
final WebTestClient webTestClient;
43+
final AccountRepository accountRepository;
44+
45+
static {
46+
try {
47+
var fullname = IntegrationTest.class.getResource("/test.cfg").getFile();
48+
System.out.println(new MothConfiguration(new File(fullname)).properties);
49+
} catch (Exception e) {
50+
System.err.println(e.getMessage());
51+
System.exit(2);
52+
}
53+
}
54+
55+
@Autowired
56+
public SearchControllerTest(WebTestClient webTestClient, AccountRepository accountRepository) {
57+
this.webTestClient = webTestClient;
58+
this.accountRepository = accountRepository;
59+
}
60+
61+
@BeforeAll
62+
static void setup() {
63+
eMongod = Mongod.builder().processOutput(Start.to(ProcessOutput.class).initializedWith(ProcessOutput.silent()))
64+
.net(Start.to(Net.class).initializedWith(Net.defaults().withPort(RAND_MONGO_PORT))).build()
65+
.start(Version.Main.V6_0);
66+
System.setProperty("spring.data.mongodb.port", Integer.toString(RAND_MONGO_PORT));
67+
}
68+
69+
@AfterAll
70+
static void clean() {
71+
eMongod.close();
72+
}
73+
74+
@Test
75+
public void checkAutoWires() {
76+
Assertions.assertNotNull(webTestClient);
77+
Assertions.assertNotNull(accountRepository);
78+
}
79+
80+
@Test
81+
public void testSearchQueryTooShortReturnsEmpty() {
82+
webTestClient.mutateWith(mockJwt().jwt(jwt -> jwt.claim("sub", "test-user"))).get()
83+
.uri(uriBuilder -> uriBuilder.path(SEARCH_ENDPOINT)
84+
.queryParam("q", "ab").build())
85+
.exchange()
86+
.expectStatus().isOk()
87+
.expectBody()
88+
.jsonPath("$.accounts.length()").isEqualTo(0)
89+
.jsonPath("$.statuses.length()").isEqualTo(0);
90+
}
91+
92+
@Test
93+
public void testSearchLocalAccount() {
94+
accountRepository.save(new Account("test-local")).block();
95+
96+
webTestClient.mutateWith(mockJwt().jwt(jwt -> jwt.claim("sub", "test-local"))).get()
97+
.uri(uriBuilder -> uriBuilder.path(SEARCH_ENDPOINT)
98+
.queryParam("q", "test-local")
99+
.queryParam("type", "accounts")
100+
.build())
101+
102+
.exchange()
103+
.expectStatus().isOk()
104+
.expectBody()
105+
.jsonPath("$.accounts[0].acct").isEqualTo("test-local");
106+
}
107+
108+
@Test
109+
public void testRemoteSearchWithInvalidDomainReturnsEmpty() {
110+
webTestClient.mutateWith(mockJwt().jwt(jwt -> jwt.claim("sub", "test-user"))).get()
111+
.uri(uriBuilder -> uriBuilder.path(SEARCH_ENDPOINT)
112+
.queryParam("q", "@user@invalid_domain")
113+
.build())
114+
115+
.exchange()
116+
.expectStatus().isOk()
117+
.expectBody()
118+
.consumeWith(response -> Assertions.assertTrue(response.getResponseBody() == null || response.getResponseBody().length == 0));
119+
}
120+
121+
// Optional: For remote test with real Mastodon instance (only for manual testing)
122+
@Test
123+
public void testRemoteSearchValidDomain() {
124+
webTestClient.mutateWith(mockJwt().jwt(jwt -> jwt.claim("sub", "test-user"))).get()
125+
.uri(uriBuilder -> uriBuilder.path(SEARCH_ENDPOINT)
126+
.queryParam("q", "@Gargron@mastodon.social")
127+
.build())
128+
129+
.exchange()
130+
.expectStatus().isOk()
131+
.expectBody()
132+
.jsonPath("$.accounts").exists(); // Will vary depending on actual Mastodon availability
133+
}
134+
135+
@Test
136+
public void testOnlyRemoteUsersAreSaved() {
137+
String localDomain = "moth.vamshiraj.me"; // Replace with actual domain logic used in app
138+
139+
// Step 1: Clear DB
140+
accountRepository.deleteAll().block();
141+
142+
// Step 2: Make a remote search request (simulate via real domain, or mock if isolated)
143+
webTestClient.mutateWith(mockJwt().jwt(jwt -> jwt.claim("sub", "test-user"))).get()
144+
.uri(uriBuilder -> uriBuilder
145+
.path(SEARCH_ENDPOINT)
146+
.queryParam("q", "@Gargron@mastodon.social") // Example remote user
147+
.queryParam("type", "accounts")
148+
.build())
149+
.exchange()
150+
.expectStatus().isOk()
151+
.expectBody()
152+
.jsonPath("$.accounts").exists();
153+
154+
// Step 3: Assert only remote users are saved
155+
var allAccounts = accountRepository.findAll().collectList().block();
156+
Assertions.assertNotNull(allAccounts);
157+
Assertions.assertFalse(allAccounts.isEmpty());
158+
159+
for (Account acc : allAccounts) {
160+
System.out.println("Saved account: " + acc.acct);
161+
Assertions.assertTrue(acc.acct.contains("@"), "Remote user acct should include domain");
162+
Assertions.assertFalse(acc.url.contains(localDomain), "Should not save local users");
163+
}
164+
}
165+
166+
}

util/.DS_Store

-6 KB
Binary file not shown.

0 commit comments

Comments
 (0)