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