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