246 lines
14 KiB
Java
246 lines
14 KiB
Java
package com.baiyue.file.service;
|
|||
|
|
|
||
|
|
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||
|
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||
|
|
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||
|
|
import com.baiyue.common.core.exception.BizException;
|
||
|
|
import com.baiyue.file.controller.InternalBusinessFileController;
|
||
|
|
import com.baiyue.file.entity.FileObjectEntity;
|
||
|
|
import com.baiyue.file.mapper.FileObjectMapper;
|
||
|
|
import org.apache.ibatis.builder.MapperBuilderAssistant;
|
||
|
|
import org.junit.jupiter.api.BeforeAll;
|
||
|
|
import org.junit.jupiter.api.io.TempDir;
|
||
|
|
import org.junit.jupiter.params.ParameterizedTest;
|
||
|
|
import org.junit.jupiter.params.provider.ValueSource;
|
||
|
|
import org.springframework.mock.web.MockMultipartFile;
|
||
|
|
import org.springframework.test.util.ReflectionTestUtils;
|
||
|
|
import org.springframework.web.multipart.MultipartFile;
|
||
|
|
|
||
|
|
import java.io.ByteArrayOutputStream;
|
||
|
|
import java.nio.charset.StandardCharsets;
|
||
|
|
import java.nio.file.Files;
|
||
|
|
import java.nio.file.Path;
|
||
|
|
import java.util.Arrays;
|
||
|
|
import java.util.concurrent.atomic.AtomicLong;
|
||
|
|
import java.util.regex.Pattern;
|
||
|
|
|
||
|
|
import static org.assertj.core.api.Assertions.*;
|
||
|
|
import static org.mockito.ArgumentMatchers.*;
|
||
|
|
import static org.mockito.Mockito.*;
|
||
|
|
|
||
|
|
class DispatchEvidenceFileTest {
|
||
|
|
private static final String VEHICLE = "OVERSEAS_DISPATCH_VEHICLE_EVIDENCE";
|
||
|
|
private static final String DRIVER = "OVERSEAS_DISPATCH_DRIVER_EVIDENCE";
|
||
|
|
private static final int MAX_BYTES = 10 * 1024 * 1024;
|
||
|
|
@TempDir Path root;
|
||
|
|
|
||
|
|
/** 初始化真实 Lambda 查询列映射,使测试能逐列核对复合归属条件。 */
|
||
|
|
@BeforeAll static void metadata() {
|
||
|
|
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), "dispatch-evidence"),
|
||
|
|
FileObjectEntity.class);
|
||
|
|
}
|
||
|
|
|
||
|
|
/** 两类证据只接受平台与有效排车内部 ID,非法输入不得访问数据库或磁盘。 */
|
||
|
|
@ParameterizedTest
|
||
|
|
@ValueSource(strings = {VEHICLE, DRIVER})
|
||
|
|
void requiresPlatformAndPositiveDispatchIdBeforeAccess(String type) {
|
||
|
|
var files = mock(FileObjectMapper.class);
|
||
|
|
var storage = mock(BusinessFileStorage.class);
|
||
|
|
var service = service(files, storage);
|
||
|
|
var upload = pdf();
|
||
|
|
for (long customer : new long[]{-1, 1, 900}) {
|
||
|
|
assertThatThrownBy(() -> service.upload(customer, type, "71", 7, upload)).isInstanceOf(BizException.class);
|
||
|
|
assertThatThrownBy(() -> service.metadata(customer, type, "71", 501)).isInstanceOf(BizException.class);
|
||
|
|
assertThatThrownBy(() -> service.download(customer, type, "71", 501)).isInstanceOf(BizException.class);
|
||
|
|
}
|
||
|
|
for (String id : new String[]{"0", "-1", "DISPATCH-71", "../71", "9223372036854775808"}) {
|
||
|
|
assertThatThrownBy(() -> service.upload(0, type, id, 7, upload)).isInstanceOf(BizException.class);
|
||
|
|
assertThatThrownBy(() -> service.metadata(0, type, id, 501)).isInstanceOf(BizException.class);
|
||
|
|
assertThatThrownBy(() -> service.download(0, type, id, 501)).isInstanceOf(BizException.class);
|
||
|
|
}
|
||
|
|
assertThatThrownBy(() -> service.upload(0, type, "71", 0, upload)).isInstanceOf(BizException.class);
|
||
|
|
assertThatThrownBy(() -> service.metadata(0, type, "71", 0)).isInstanceOf(BizException.class);
|
||
|
|
assertThatThrownBy(() -> service.download(0, type, "71", 0)).isInstanceOf(BizException.class);
|
||
|
|
verifyNoInteractions(files, storage);
|
||
|
|
}
|
||
|
|
|
||
|
|
/** 真实 PDF、JPG、PNG 均可写入并保持内容;上传不以扩展名代替 MIME 和签名。 */
|
||
|
|
@ParameterizedTest
|
||
|
|
@ValueSource(strings = {VEHICLE, DRIVER})
|
||
|
|
void storesPdfJpegPngAndRejectsEmptyUnsupportedAndSpoofedContent(String type) throws Exception {
|
||
|
|
var storage = storage();
|
||
|
|
var category = BusinessFileCategory.require(type);
|
||
|
|
for (var file : new MockMultipartFile[]{pdf(),
|
||
|
|
new MockMultipartFile("file", "vehicle.jpg", "image/jpeg", image("jpeg")),
|
||
|
|
new MockMultipartFile("file", "driver.png", "image/png", image("png"))}) {
|
||
|
|
var saved = storage.store(0, "71", file, category);
|
||
|
|
assertThat(storage.read(saved.objectKey())).isEqualTo(file.getBytes());
|
||
|
|
assertThat(saved.contentType()).isEqualTo(file.getContentType());
|
||
|
|
assertThat(saved.size()).isEqualTo(file.getSize());
|
||
|
|
assertThat(saved.sha256()).matches("[0-9a-f]{64}");
|
||
|
|
}
|
||
|
|
for (var file : new MockMultipartFile[]{
|
||
|
|
new MockMultipartFile("file", "empty.pdf", "application/pdf", new byte[0]),
|
||
|
|
new MockMultipartFile("file", "fake.pdf", "application/pdf", "plain text".getBytes(StandardCharsets.UTF_8)),
|
||
|
|
new MockMultipartFile("file", "fake.jpg", "image/jpeg", image("png")),
|
||
|
|
new MockMultipartFile("file", "fake.png", "image/png", image("jpeg")),
|
||
|
|
new MockMultipartFile("file", "fake.pdf", "text/plain", pdf().getBytes()),
|
||
|
|
new MockMultipartFile("file", "fake.pdf", "application/octet-stream", pdf().getBytes()),
|
||
|
|
new MockMultipartFile("file", "missing.pdf", null, pdf().getBytes())}) {
|
||
|
|
assertThatThrownBy(() -> storage.store(0, "71", file, category)).isInstanceOf(BizException.class);
|
||
|
|
}
|
||
|
|
assertThatThrownBy(() -> storage.store(0, "71", null, category)).isInstanceOf(BizException.class);
|
||
|
|
}
|
||
|
|
|
||
|
|
/** 10MiB 为包含边界,超限在读取内容或落盘前拒绝。 */
|
||
|
|
@ParameterizedTest
|
||
|
|
@ValueSource(strings = {VEHICLE, DRIVER})
|
||
|
|
void acceptsExactlyTenMibAndRejectsOneByteOverBeforeReading(String type) throws Exception {
|
||
|
|
var storage = storage();
|
||
|
|
var category = BusinessFileCategory.require(type);
|
||
|
|
byte[] content = Arrays.copyOf(pdf().getBytes(), MAX_BYTES);
|
||
|
|
var boundary = new MockMultipartFile("file", "boundary.pdf", "application/pdf", content);
|
||
|
|
var stored = storage.store(0, "71", boundary, category);
|
||
|
|
assertThat(Files.size(root.resolve(stored.objectKey()))).isEqualTo(MAX_BYTES);
|
||
|
|
var oversized = mock(MultipartFile.class);
|
||
|
|
when(oversized.isEmpty()).thenReturn(false);
|
||
|
|
when(oversized.getSize()).thenReturn((long) MAX_BYTES + 1);
|
||
|
|
assertThatThrownBy(() -> storage.store(0, "71", oversized, category))
|
||
|
|
.isInstanceOf(BizException.class).hasMessageContaining("10MB");
|
||
|
|
verify(oversized, never()).getInputStream();
|
||
|
|
verify(oversized, never()).transferTo(any(Path.class));
|
||
|
|
}
|
||
|
|
|
||
|
|
/** 上传生成独立不可变对象和真实元数据;替换引用不覆盖原文件。 */
|
||
|
|
@ParameterizedTest
|
||
|
|
@ValueSource(strings = {VEHICLE, DRIVER})
|
||
|
|
void uploadsKeepPlatformDispatchScopeAndImmutableObjects(String type) throws Exception {
|
||
|
|
var files = mock(FileObjectMapper.class);
|
||
|
|
var storage = storage();
|
||
|
|
var service = service(files, storage);
|
||
|
|
var ids = new AtomicLong(500);
|
||
|
|
when(files.insert(any(FileObjectEntity.class))).thenAnswer(call -> {
|
||
|
|
FileObjectEntity row = call.getArgument(0);
|
||
|
|
row.setId(ids.incrementAndGet());
|
||
|
|
return 1;
|
||
|
|
});
|
||
|
|
var first = service.upload(0, type, "71", 7, pdf());
|
||
|
|
var secondFile = new MockMultipartFile("file", "updated.pdf", "application/pdf",
|
||
|
|
"%PDF-1.7\nupdated".getBytes(StandardCharsets.US_ASCII));
|
||
|
|
var second = service.upload(0, type, "71", 7, secondFile);
|
||
|
|
var rows = org.mockito.ArgumentCaptor.forClass(FileObjectEntity.class);
|
||
|
|
verify(files, times(2)).insert(rows.capture());
|
||
|
|
assertThat(first.ownerScope()).isEqualTo("PLATFORM");
|
||
|
|
assertThat(first.tenantId()).isZero();
|
||
|
|
assertThat(first.businessType()).isEqualTo(type);
|
||
|
|
assertThat(first.businessId()).isEqualTo("71");
|
||
|
|
assertThat(first.fileId()).isEqualTo(501L);
|
||
|
|
assertThat(second.fileId()).isEqualTo(502L);
|
||
|
|
assertThat(first.fileName()).isEqualTo("evidence.pdf");
|
||
|
|
assertThat(first.contentType()).isEqualTo("application/pdf");
|
||
|
|
assertThat(first.sizeBytes()).isEqualTo(pdf().getSize());
|
||
|
|
assertThat(first.sha256()).matches("[0-9a-f]{64}");
|
||
|
|
assertThat(first.createdAt()).isNotNull().isEqualTo(rows.getAllValues().get(0).getCreatedAt());
|
||
|
|
assertThat(rows.getAllValues().get(0).getObjectKey()).isNotEqualTo(rows.getAllValues().get(1).getObjectKey());
|
||
|
|
assertThat(storage.read(rows.getAllValues().get(0).getObjectKey())).isEqualTo(pdf().getBytes());
|
||
|
|
assertThat(storage.read(rows.getAllValues().get(1).getObjectKey())).isEqualTo(secondFile.getBytes());
|
||
|
|
verify(files, never()).updateById(any(FileObjectEntity.class));
|
||
|
|
}
|
||
|
|
|
||
|
|
/** 元数据和下载均逐列带全复合键;错车、错类别、错文件不得读取存储内容。 */
|
||
|
|
@ParameterizedTest
|
||
|
|
@ValueSource(strings = {VEHICLE, DRIVER})
|
||
|
|
void metadataAndDownloadEnforceEveryScopeKeyBeforeReading(String type) throws Exception {
|
||
|
|
var files = mock(FileObjectMapper.class);
|
||
|
|
var storage = mock(BusinessFileStorage.class);
|
||
|
|
var service = service(files, storage);
|
||
|
|
var row = new FileObjectEntity();
|
||
|
|
row.setId(501L); row.setTenantId(0L); row.setBusinessType(type); row.setBusinessId("71");
|
||
|
|
row.setOriginalName("evidence.pdf"); row.setContentType("application/pdf");
|
||
|
|
row.setObjectKey("business-files/0/71/evidence.pdf"); row.setSizeBytes(pdf().getSize());
|
||
|
|
row.setSha256("a".repeat(64)); row.setCreatedAt(java.time.LocalDateTime.now());
|
||
|
|
when(files.selectOne(any())).thenAnswer(call -> {
|
||
|
|
LambdaQueryWrapper<FileObjectEntity> query = call.getArgument(0);
|
||
|
|
assertThat(boundValue(query, "status")).isEqualTo("ACTIVE");
|
||
|
|
assertThat(boundValue(query, "deleted")).isEqualTo(false);
|
||
|
|
return Long.valueOf(0).equals(boundValue(query, "tenant_id"))
|
||
|
|
&& type.equals(boundValue(query, "business_type"))
|
||
|
|
&& "71".equals(boundValue(query, "business_id"))
|
||
|
|
&& Long.valueOf(501).equals(boundValue(query, "id")) ? row : null;
|
||
|
|
});
|
||
|
|
when(storage.read(row.getObjectKey())).thenReturn(pdf().getBytes());
|
||
|
|
var metadata = service.metadata(0, type, "71", 501);
|
||
|
|
assertThat(metadata.fileId()).isEqualTo(501L);
|
||
|
|
assertThat(metadata.ownerScope()).isEqualTo("PLATFORM");
|
||
|
|
assertThat(service.download(0, type, "71", 501).content()).isEqualTo(pdf().getBytes());
|
||
|
|
verify(storage).read(row.getObjectKey());
|
||
|
|
clearInvocations(storage);
|
||
|
|
String otherType = VEHICLE.equals(type) ? DRIVER : VEHICLE;
|
||
|
|
for (String candidate : new String[]{otherType, "FLEET_DRIVER_LICENSE", "FLEET_TRIP_OPERATION_EVIDENCE"}) {
|
||
|
|
assertThatThrownBy(() -> service.metadata(0, candidate, "71", 501))
|
||
|
|
.isInstanceOf(BizException.class).hasMessageContaining("归属不匹配");
|
||
|
|
assertThatThrownBy(() -> service.download(0, candidate, "71", 501))
|
||
|
|
.isInstanceOf(BizException.class).hasMessageContaining("归属不匹配");
|
||
|
|
}
|
||
|
|
assertThatThrownBy(() -> service.metadata(0, type, "72", 501)).isInstanceOf(BizException.class);
|
||
|
|
assertThatThrownBy(() -> service.download(0, type, "72", 501)).isInstanceOf(BizException.class);
|
||
|
|
assertThatThrownBy(() -> service.metadata(0, type, "71", 502)).isInstanceOf(BizException.class);
|
||
|
|
assertThatThrownBy(() -> service.download(0, type, "71", 502)).isInstanceOf(BizException.class);
|
||
|
|
verifyNoInteractions(storage);
|
||
|
|
}
|
||
|
|
|
||
|
|
/** 内部服务令牌在业务访问前校验,不对未授权请求暴露元数据或文件。 */
|
||
|
|
@ParameterizedTest
|
||
|
|
@ValueSource(strings = {VEHICLE, DRIVER})
|
||
|
|
void invalidInternalTokenCannotUploadReadMetadataOrDownload(String type) {
|
||
|
|
var service = mock(InternalBusinessFileService.class);
|
||
|
|
var controller = new InternalBusinessFileController();
|
||
|
|
ReflectionTestUtils.setField(controller, "expected", "dispatch-test-token");
|
||
|
|
ReflectionTestUtils.setField(controller, "service", service);
|
||
|
|
for (String token : new String[]{null, "", "wrong-token"}) {
|
||
|
|
assertThatThrownBy(() -> controller.upload(token, 7, 0, type, "71", pdf())).isInstanceOf(BizException.class);
|
||
|
|
assertThatThrownBy(() -> controller.metadata(token, 0, type, "71", 501)).isInstanceOf(BizException.class);
|
||
|
|
assertThatThrownBy(() -> controller.download(token, 0, type, "71", 501)).isInstanceOf(BizException.class);
|
||
|
|
}
|
||
|
|
verifyNoInteractions(service);
|
||
|
|
}
|
||
|
|
|
||
|
|
/** 从实际 SQL 绑定中取指定列的等值条件,缺少任一隔离条件直接失败。 */
|
||
|
|
private static Object boundValue(LambdaQueryWrapper<FileObjectEntity> query, String column) {
|
||
|
|
var matcher = Pattern.compile("(?<![A-Za-z0-9_])" + column
|
||
|
|
+ " = #\\{ew\\.paramNameValuePairs\\.([^}]+)}").matcher(query.getSqlSegment());
|
||
|
|
assertThat(matcher.find()).as("复合归属条件 %s", column).isTrue();
|
||
|
|
return query.getParamNameValuePairs().get(matcher.group(1));
|
||
|
|
}
|
||
|
|
|
||
|
|
/** 创建隔离临时目录中的真实存储,不访问运行环境。 */
|
||
|
|
private BusinessFileStorage storage() {
|
||
|
|
var storage = new BusinessFileStorage();
|
||
|
|
ReflectionTestUtils.setField(storage, "configuredRoot", root);
|
||
|
|
storage.initializeRoot();
|
||
|
|
return storage;
|
||
|
|
}
|
||
|
|
|
||
|
|
/** 复用生产服务,数据库边界由 Mapper 替身记录实际查询。 */
|
||
|
|
private InternalBusinessFileService service(FileObjectMapper files, BusinessFileStorage storage) {
|
||
|
|
var service = new InternalBusinessFileService();
|
||
|
|
ReflectionTestUtils.setField(service, "files", files);
|
||
|
|
ReflectionTestUtils.setField(service, "storage", storage);
|
||
|
|
return service;
|
||
|
|
}
|
||
|
|
|
||
|
|
/** 提供具有真实 PDF 文件头的最小上传内容。 */
|
||
|
|
private static MockMultipartFile pdf() {
|
||
|
|
return new MockMultipartFile("file", "evidence.pdf", "application/pdf",
|
||
|
|
"%PDF-1.7\nevidence".getBytes(StandardCharsets.US_ASCII));
|
||
|
|
}
|
||
|
|
|
||
|
|
/** 使用图像编码器生成真实 JPG、PNG,避免测试只验证扩展名。 */
|
||
|
|
private static byte[] image(String format) throws java.io.IOException {
|
||
|
|
var output = new ByteArrayOutputStream();
|
||
|
|
var image = new java.awt.image.BufferedImage(2, 2, java.awt.image.BufferedImage.TYPE_INT_RGB);
|
||
|
|
assertThat(javax.imageio.ImageIO.write(image, format, output)).isTrue();
|
||
|
|
return output.toByteArray();
|
||
|
|
}
|
||
|
|
}
|