You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

275 lines
18 KiB

package com.mydreamjob.profileimage;
import com.fasterxml.jackson.databind.*;
import com.mydreamjob.auth.*;
import com.mydreamjob.audit.AuditContext;
import com.mydreamjob.company.*;
import com.mydreamjob.messaging.*;
import org.junit.jupiter.api.*;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.mock.web.MockMultipartFile;
import java.time.Clock;
import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicLong;
import java.io.*;
import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import static org.junit.jupiter.api.Assertions.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@SpringBootTest(properties = "spring.datasource.url=jdbc:h2:mem:integrationdb_profile_images;MODE=MySQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE")
@AutoConfigureMockMvc @ActiveProfiles("integration-test")
class ProfileImageIntegrationTest {
@Autowired MockMvc mvc; @Autowired ObjectMapper json;
@Autowired UserRepository users; @Autowired CompanyRepository companies;
@Autowired ConversationRepository conversations; @Autowired ConversationMemberRepository members;
@Autowired AuthSessionService sessions; @Autowired Clock clock;
@Autowired ProfileImageRepository images; @Autowired ProfileImageService service;
@Autowired org.springframework.transaction.PlatformTransactionManager transactions;
@Autowired com.mydreamjob.deletion.AccountDeletionRepository deletions;
@Autowired com.mydreamjob.deletion.AccountDeletionCompletionWorker deletionWorker;
@MockBean RedisTemplate<String, Object> redis;
static final AtomicLong SEQ = new AtomicLong(76000000);
User student, peer, employer, stranger; String st, pt, et, ot; Company company;
User user(User.Role role) {
return users.save(User.builder().phone("166" + SEQ.incrementAndGet()).nickname("头像测试")
.role(role).roles(Set.of(role)).active(true).verified(true).build());
}
String token(User user) { return sessions.createSession(user, clock.instant(), new AuditContext()).accessToken(); }
@BeforeEach void setup() {
student = user(User.Role.STUDENT); peer = user(User.Role.STUDENT);
employer = user(User.Role.COMPANY); stranger = user(User.Role.STUDENT);
st = token(student); pt = token(peer); et = token(employer); ot = token(stranger);
company = companies.save(Company.builder().ownerUserId(employer.getId()).name("头像测试企业").verified(true).build());
}
MockMultipartFile image(int width, int height) throws Exception {
var out = new ByteArrayOutputStream();
ImageIO.write(new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB), "png", out);
return new MockMultipartFile("file", "头像.png", "image/png", out.toByteArray());
}
JsonNode upload(String path, String token) throws Exception {
var response = mvc.perform(multipart(path).file(image(800, 400)).header("Authorization", "Bearer " + token))
.andExpect(status().isOk()).andReturn().getResponse();
return json.readTree(response.getContentAsByteArray()).path("data");
}
String path(JsonNode view) { return view.path("url").asText().replaceFirst("^/api/v1", ""); }
long direct(User a, User b, String type) {
long id = conversations.save(Conversation.builder().createdBy(a.getId()).type(type).build()).getId();
for (User user : List.of(a, b)) members.save(ConversationMember.builder().conversationId(id).userId(user.getId()).build());
return id;
}
@Test void personalRoundTripAndAuthMeUseSameVersion() throws Exception {
var view = upload("/auth/me/avatar", st);
assertEquals("image/jpeg", view.path("contentType").asText());
assertTrue(view.path("size").asLong() <= ProfileImageValidator.MAX_STORED_BYTES);
assertEquals("/api/v1" + path(view), users.findById(student.getId()).orElseThrow().getAvatarUrl());
mvc.perform(get("/auth/me").header("Authorization", "Bearer " + st))
.andExpect(jsonPath("$.data.avatarUrl").value(view.path("url").asText()));
byte[] bytes = mvc.perform(get(path(view)).header("Authorization", "Bearer " + st))
.andExpect(status().isOk()).andExpect(content().contentType("image/jpeg"))
.andExpect(header().string("Cache-Control", "no-store"))
.andExpect(header().string("X-Content-Type-Options", "nosniff"))
.andReturn().getResponse().getContentAsByteArray();
var decoded = ImageIO.read(new ByteArrayInputStream(bytes));
assertEquals(512, decoded.getWidth()); assertEquals(256, decoded.getHeight());
}
@Test void peerSeesCurrentAvatarAndOldUrlIsInvalidated() throws Exception {
long cid = direct(student, peer, "DIRECT");
var first = upload("/auth/me/avatar", st);
mvc.perform(get(path(first)).header("Authorization", "Bearer " + pt)).andExpect(status().isOk());
var second = upload("/auth/me/avatar", st);
assertNotEquals(first.path("url"), second.path("url"));
mvc.perform(get(path(first)).header("Authorization", "Bearer " + pt)).andExpect(status().isNotFound());
mvc.perform(get("/conversations/" + cid).header("Authorization", "Bearer " + pt))
.andExpect(status().isOk()).andExpect(jsonPath("$.data.avatarUrl").value(second.path("url").asText()));
mvc.perform(get("/conversations").header("Authorization", "Bearer " + pt))
.andExpect(jsonPath("$.data[0].avatarUrl").value(second.path("url").asText()));
}
@Test void avatarAnonymousAndNonMemberDenied() throws Exception {
var view = upload("/auth/me/avatar", st);
mvc.perform(get(path(view))).andExpect(status().isUnauthorized());
mvc.perform(get(path(view)).header("Authorization", "Bearer " + ot)).andExpect(status().isForbidden());
mvc.perform(multipart("/auth/me/avatar").file(image(2, 2))).andExpect(status().isUnauthorized());
}
@Test void invitationMembersCanReadAvatarWithoutMakingItPublic() throws Exception {
direct(student, peer, "TALENT_INVITATION");
var view = upload("/auth/me/avatar", st);
mvc.perform(get(path(view)).header("Authorization", "Bearer " + pt)).andExpect(status().isOk());
mvc.perform(get(path(view))).andExpect(status().isUnauthorized());
mvc.perform(get(path(view)).header("Authorization", "Bearer " + ot)).andExpect(status().isForbidden());
}
@Test void profileNameSaveImmediatelyChangesBothConversationResponses() throws Exception {
long cid=direct(student,employer,"DIRECT");
for(String type:List.of("DIRECT","TALENT_INVITATION")) {
var conversation=conversations.findById(cid).orElseThrow();conversation.setType(type);conversations.saveAndFlush(conversation);
for(String name:List.of("修改姓名一","修改姓名二")) {
mvc.perform(put("/profile/me").header("Authorization","Bearer "+st).contentType("application/json")
.content(json.writeValueAsString(Map.of("realName",name)))).andExpect(status().isOk());
mvc.perform(get("/conversations/"+cid).header("Authorization","Bearer "+et))
.andExpect(status().isOk()).andExpect(jsonPath("$.data.contactName").value(name));
mvc.perform(get("/conversations").header("Authorization","Bearer "+et))
.andExpect(status().isOk()).andExpect(jsonPath("$.data[0].contactName").value(name));
}
}
assertEquals("头像测试",users.findById(student.getId()).orElseThrow().getNickname());
}
@Test void invitationAvatarStillHonorsBlocking() throws Exception {
direct(student,peer,"TALENT_INVITATION"); var view=upload("/auth/me/avatar",st);
mvc.perform(put("/blocks/"+student.getId()).header("Authorization","Bearer "+pt)).andExpect(status().isOk());
mvc.perform(get(path(view)).header("Authorization","Bearer "+pt)).andExpect(status().isForbidden());
}
@Test void blockRevokesExistingAvatarUrl() throws Exception {
direct(student, peer, "DIRECT");
var view = upload("/auth/me/avatar", st);
mvc.perform(put("/blocks/" + student.getId()).header("Authorization", "Bearer " + pt)).andExpect(status().isOk());
mvc.perform(get(path(view)).header("Authorization", "Bearer " + pt)).andExpect(status().isForbidden());
}
@Test void disabledOwnerAndReaderDenied() throws Exception {
direct(student, peer, "DIRECT");
var view = upload("/auth/me/avatar", st);
peer.setActive(false); users.saveAndFlush(peer);
mvc.perform(get(path(view)).header("Authorization", "Bearer " + pt)).andExpect(status().isUnauthorized());
student.setActive(false); users.saveAndFlush(student);
mvc.perform(get(path(view))).andExpect(status().isNotFound());
mvc.perform(multipart("/auth/me/avatar").file(image(2, 2)).header("Authorization", "Bearer " + st))
.andExpect(status().isUnauthorized());
}
@Test void personalAndLogoAreIndependentAndLogoPublic() throws Exception {
var avatar = upload("/auth/me/avatar", et);
var logo = upload("/companies/me/logo", et);
assertNotEquals(avatar.path("url"), logo.path("url"));
assertEquals(logo.path("url").asText(), companies.findById(company.getId()).orElseThrow().getLogoUrl());
assertEquals(avatar.path("url").asText(), users.findById(employer.getId()).orElseThrow().getAvatarUrl());
mvc.perform(get(path(logo))).andExpect(status().isOk());
mvc.perform(get("/companies/" + company.getId())).andExpect(jsonPath("$.data.logoUrl").value(logo.path("url").asText()));
mvc.perform(delete("/auth/me/avatar").header("Authorization", "Bearer " + et)).andExpect(status().isOk());
mvc.perform(get(path(logo))).andExpect(status().isOk());
mvc.perform(get(path(avatar)).header("Authorization", "Bearer " + et)).andExpect(status().isNotFound());
}
@Test void companyConversationUsesLogoNotRecruiterAvatar() throws Exception {
direct(student, employer, "DIRECT");
var avatar = upload("/auth/me/avatar", et);
var logo = upload("/companies/me/logo", et);
mvc.perform(get("/conversations").header("Authorization", "Bearer " + st))
.andExpect(status().isOk()).andExpect(jsonPath("$.data[0].avatarUrl").value(logo.path("url").asText()));
assertNotEquals(avatar.path("url"), logo.path("url"));
}
@Test void nonCompanyAndCompanyWithoutProfileCannotUploadLogo() throws Exception {
mvc.perform(multipart("/companies/me/logo").file(image(2, 2)).header("Authorization", "Bearer " + st))
.andExpect(status().isForbidden());
var noProfile = token(user(User.Role.COMPANY));
mvc.perform(multipart("/companies/me/logo").file(image(2, 2)).header("Authorization", "Bearer " + noProfile))
.andExpect(status().isNotFound());
assertTrue(images.findByOwnerUserIdAndKind(student.getId(), "LOGO").isEmpty());
}
@Test void deletesAreIdempotentAndCannotTouchOtherOwner() throws Exception {
var own = upload("/auth/me/avatar", st); var other = upload("/auth/me/avatar", pt);
for (int i = 0; i < 2; i++) mvc.perform(delete("/auth/me/avatar").header("Authorization", "Bearer " + st))
.andExpect(status().isOk());
mvc.perform(get(path(own)).header("Authorization", "Bearer " + st)).andExpect(status().isNotFound());
mvc.perform(get(path(other)).header("Authorization", "Bearer " + pt)).andExpect(status().isOk());
var logo = upload("/companies/me/logo", et);
mvc.perform(delete("/companies/me/logo").header("Authorization", "Bearer " + et)).andExpect(status().isOk());
mvc.perform(get(path(logo))).andExpect(status().isNotFound());
}
@Test void invalidUploadPreservesOldReferenceAndBytes() throws Exception {
var view = upload("/auth/me/avatar", st);
for (var invalid : List.of(
new MockMultipartFile("file", "bad.png", "image/png", new byte[]{1, 2}),
new MockMultipartFile("file", "bad.svg", "image/svg+xml", "<svg/>".getBytes()),
new MockMultipartFile("file", "../bad.png", "image/png", image(2, 2).getBytes()),
new MockMultipartFile("file", "bad.jpg", "image/jpeg", image(2, 2).getBytes()),
new MockMultipartFile("file", "big.png", "image/png", new byte[ProfileImageValidator.MAX_UPLOAD_BYTES + 1]),
image(4097, 1))) {
mvc.perform(multipart("/auth/me/avatar").file(invalid).header("Authorization", "Bearer " + st))
.andExpect(status().isBadRequest());
}
mvc.perform(get(path(view)).header("Authorization", "Bearer " + st)).andExpect(status().isOk());
assertEquals(view.path("url").asText(), users.findById(student.getId()).orElseThrow().getAvatarUrl());
assertEquals(1, images.findAll().stream().filter(i -> i.getOwnerUserId().equals(student.getId())).count());
}
@Test void missingFileIs400AndMalformedIdIs404() throws Exception {
mvc.perform(multipart("/auth/me/avatar").header("Authorization", "Bearer " + st)).andExpect(status().isBadRequest());
mvc.perform(get("/profile-images/not-a-uuid")).andExpect(status().isNotFound());
}
@Test void transactionRollbackRestoresOldImage() throws Exception {
var first = upload("/auth/me/avatar", st);
var tx = new org.springframework.transaction.support.TransactionTemplate(transactions);
tx.executeWithoutResult(status -> {
try { service.upload(student.getId(), false, image(5, 5)); }
catch (Exception e) { throw new IllegalStateException(e); }
status.setRollbackOnly();
});
mvc.perform(get(path(first)).header("Authorization", "Bearer " + st)).andExpect(status().isOk());
assertEquals(first.path("url").asText(), users.findById(student.getId()).orElseThrow().getAvatarUrl());
}
@Test void concurrentReplacementLeavesOneCurrentImage() throws Exception {
var pool = Executors.newFixedThreadPool(2);
try {
var a = pool.submit(() -> upload("/auth/me/avatar", st));
var b = pool.submit(() -> upload("/auth/me/avatar", st));
var first = a.get(30, TimeUnit.SECONDS); var second = b.get(30, TimeUnit.SECONDS);
assertNotEquals(first.path("url"), second.path("url"));
assertEquals(1, images.findAll().stream().filter(i -> i.getOwnerUserId().equals(student.getId())).count());
String current = users.findById(student.getId()).orElseThrow().getAvatarUrl();
assertTrue(Set.of(first.path("url").asText(), second.path("url").asText()).contains(current));
mvc.perform(get(current.replaceFirst("^/api/v1", "")).header("Authorization", "Bearer " + st)).andExpect(status().isOk());
} finally { pool.shutdownNow(); }
}
@Test void accountDeletionPurgesImageBytes() throws Exception {
var view = upload("/auth/me/avatar", st);
deletions.saveAndFlush(com.mydreamjob.deletion.AccountDeletion.builder().userId(student.getId())
.status(com.mydreamjob.deletion.DeletionStatus.PENDING)
.requestedAt(java.time.LocalDateTime.now().minusDays(9))
.effectiveAt(java.time.LocalDateTime.now().minusDays(1)).version(0L).build());
assertTrue(deletionWorker.completeIfExpired(student.getId()));
assertFalse(images.existsById(view.path("version").asText()));
mvc.perform(get(path(view))).andExpect(status().isNotFound());
}
@Test void jpegAcceptedAndPixelBombRejected() throws Exception {
var out = new ByteArrayOutputStream();
ImageIO.write(new BufferedImage(12, 10, BufferedImage.TYPE_INT_RGB), "jpeg", out);
mvc.perform(multipart("/auth/me/avatar").file(new MockMultipartFile("file", "avatar.jpg", "image/jpeg", out.toByteArray()))
.header("Authorization", "Bearer " + st)).andExpect(status().isOk());
mvc.perform(multipart("/auth/me/avatar").file(image(2001, 2000))
.header("Authorization", "Bearer " + st)).andExpect(status().isBadRequest());
}
@Test void unknownAndUnassignedUsersCannotWriteOrReadPrivateAvatar() throws Exception {
var unassigned = user(User.Role.UNASSIGNED); var token = token(unassigned);
var own = upload("/auth/me/avatar", st);
mvc.perform(multipart("/auth/me/avatar").file(image(2, 2)).header("Authorization", "Bearer " + token))
.andExpect(status().isForbidden());
mvc.perform(get(path(own)).header("Authorization", "Bearer " + token)).andExpect(status().isForbidden());
}
@Test void runtimeContractContainsOnlyTheFiveNewOperations() throws Exception {
var response = mvc.perform(get("/v3/api-docs")).andExpect(status().isOk()).andReturn().getResponse();
var runtime = json.readTree(response.getContentAsByteArray()).path("paths");
var contract = json.readTree(java.nio.file.Files.readString(java.nio.file.Path.of("../docs/openapi-rc.json"))).path("paths");
assertEquals(222, runtime.size());
Set<String> expected = new TreeSet<>(), actual = new TreeSet<>();
contract.fields().forEachRemaining(p -> p.getValue().fieldNames().forEachRemaining(m -> expected.add(m + " " + p.getKey())));
runtime.fields().forEachRemaining(p -> p.getValue().fieldNames().forEachRemaining(m -> actual.add(m + " " + p.getKey())));
assertEquals(272, actual.size()); assertEquals(expected, actual);
assertEquals("uploadAvatar", runtime.path("/auth/me/avatar").path("post").path("operationId").asText());
for (String route : List.of("/auth/me/avatar", "/companies/me/logo", "/profile-images/{imageId}")) {
contract.path(route).fields().forEachRemaining(method -> {
assertEquals(method.getValue().path("operationId"),
runtime.path(route).path(method.getKey()).path("operationId"));
});
}
assertFalse(runtime.path("/profile-images/{imageId}").path("get").path("security").toString().contains("bearerAuth"));
}
}