• I completed the next plan step and updated the plan. Security review is now marked done in docs/video-clipping-service-implementation-plan.md:701.

The security hardening I implemented was tenant isolation across the API surface. X-Tenant-Id is now enforced on asset reads, upload completion, asset/job event reads, clip listing, job reads, cancellation, deletion, and
  signed download URL creation. Cross-tenant access returns 403 via the new src/main/java/org/example/videoclips/application/ForbiddenException.java:1 and handler wiring in src/main/java/org/example/videoclips/api/
  ApiExceptionHandler.java:1. I also stopped exposing providerUploadId from the upload-session read response in src/main/java/org/example/videoclips/application/VideoAssetService.java:224, since that provider identifier is
  internal metadata.

  Verification: mvn -q test passed.

  - TenantQuotaControllerTest: 1/1
  - VideoAssetControllerTest: 11/11
  - RetentionCleanupJobTest: 2/2
  - DatabaseBackedClipJobQueueAdapterTest: 2/2

  The production-hardening section of the plan is now fully checked off.
This commit is contained in:
JSLMPR 2026-07-09 01:19:43 +02:00
parent a660b7c1d8
commit e18c2cd9a9
9 changed files with 152 additions and 57 deletions

View File

@ -706,7 +706,7 @@ Keep the domain independent from Spring framework details. Adapters implement st
4. [x] Tenant quotas. 4. [x] Tenant quotas.
5. [x] Full `application/problem+json` error model. 5. [x] Full `application/problem+json` error model.
6. [x] Cleanup lifecycle jobs. 6. [x] Cleanup lifecycle jobs.
7. [~] Security review. 7. [x] Security review.
### Performance Tuning ### Performance Tuning

View File

@ -2,6 +2,7 @@ package org.example.videoclips.api;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.ConstraintViolationException; import jakarta.validation.ConstraintViolationException;
import org.example.videoclips.application.ForbiddenException;
import org.example.videoclips.application.NotFoundException; import org.example.videoclips.application.NotFoundException;
import org.example.videoclips.application.PayloadTooLargeException; import org.example.videoclips.application.PayloadTooLargeException;
import org.example.videoclips.application.QuotaExceededException; import org.example.videoclips.application.QuotaExceededException;
@ -34,6 +35,11 @@ public class ApiExceptionHandler extends ResponseEntityExceptionHandler {
return problem(HttpStatus.NOT_FOUND, "not-found", ex.getMessage(), request.getRequestURI(), null); return problem(HttpStatus.NOT_FOUND, "not-found", ex.getMessage(), request.getRequestURI(), null);
} }
@ExceptionHandler(ForbiddenException.class)
ProblemDetail handleForbidden(ForbiddenException ex, HttpServletRequest request) {
return problem(HttpStatus.FORBIDDEN, "forbidden", ex.getMessage(), request.getRequestURI(), null);
}
@ExceptionHandler(StateConflictException.class) @ExceptionHandler(StateConflictException.class)
ProblemDetail handleConflict(StateConflictException ex, HttpServletRequest request) { ProblemDetail handleConflict(StateConflictException ex, HttpServletRequest request) {
return problem(HttpStatus.CONFLICT, "state-conflict", ex.getMessage(), request.getRequestURI(), null); return problem(HttpStatus.CONFLICT, "state-conflict", ex.getMessage(), request.getRequestURI(), null);

View File

@ -4,6 +4,7 @@ import org.example.videoclips.api.dto.SignedUrlResponse;
import org.example.videoclips.application.VideoAssetService; import org.example.videoclips.application.VideoAssetService;
import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
@ -18,7 +19,10 @@ public class ClipController {
} }
@PostMapping("/{clipId}/download-url") @PostMapping("/{clipId}/download-url")
public SignedUrlResponse createClipDownloadUrl(@PathVariable String clipId) { public SignedUrlResponse createClipDownloadUrl(
return videoAssetService.createClipDownloadUrl(clipId); @PathVariable String clipId,
@RequestHeader(value = "X-Tenant-Id", required = false) String tenantId
) {
return videoAssetService.createClipDownloadUrl(clipId, tenantId);
} }
} }

View File

@ -4,6 +4,7 @@ import org.example.videoclips.application.VideoAssetService;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
@ -18,22 +19,34 @@ public class ClipJobController {
} }
@GetMapping("/{jobId}") @GetMapping("/{jobId}")
public Object getJob(@PathVariable String jobId) { public Object getJob(
return videoAssetService.getJob(jobId); @PathVariable String jobId,
@RequestHeader(value = "X-Tenant-Id", required = false) String tenantId
) {
return videoAssetService.getJob(jobId, tenantId);
} }
@GetMapping("/{jobId}/clips") @GetMapping("/{jobId}/clips")
public Object listClips(@PathVariable String jobId) { public Object listClips(
return videoAssetService.listClips(jobId); @PathVariable String jobId,
@RequestHeader(value = "X-Tenant-Id", required = false) String tenantId
) {
return videoAssetService.listClips(jobId, tenantId);
} }
@GetMapping("/{jobId}/events") @GetMapping("/{jobId}/events")
public Object listJobEvents(@PathVariable String jobId) { public Object listJobEvents(
return videoAssetService.listJobEvents(jobId); @PathVariable String jobId,
@RequestHeader(value = "X-Tenant-Id", required = false) String tenantId
) {
return videoAssetService.listJobEvents(jobId, tenantId);
} }
@PostMapping("/{jobId}:cancel") @PostMapping("/{jobId}:cancel")
public Object cancelJob(@PathVariable String jobId) { public Object cancelJob(
return videoAssetService.cancelJob(jobId); @PathVariable String jobId,
@RequestHeader(value = "X-Tenant-Id", required = false) String tenantId
) {
return videoAssetService.cancelJob(jobId, tenantId);
} }
} }

View File

@ -37,28 +37,44 @@ public class VideoAssetController {
} }
@PostMapping("/{assetId}/uploads:complete") @PostMapping("/{assetId}/uploads:complete")
public Object completeUpload(@PathVariable String assetId, @Valid @RequestBody CompleteUploadRequest request) { public Object completeUpload(
return videoAssetService.completeUpload(assetId, request); @PathVariable String assetId,
@Valid @RequestBody CompleteUploadRequest request,
@RequestHeader(value = "X-Tenant-Id", required = false) String tenantId
) {
return videoAssetService.completeUpload(assetId, request, tenantId);
} }
@GetMapping("/{assetId}") @GetMapping("/{assetId}")
public Object getAsset(@PathVariable String assetId) { public Object getAsset(
return videoAssetService.getAssetResponse(assetId); @PathVariable String assetId,
@RequestHeader(value = "X-Tenant-Id", required = false) String tenantId
) {
return videoAssetService.getAssetResponse(assetId, tenantId);
} }
@GetMapping("/{assetId}/upload-session") @GetMapping("/{assetId}/upload-session")
public Object getUploadSession(@PathVariable String assetId) { public Object getUploadSession(
return videoAssetService.getUploadSessionResponse(assetId); @PathVariable String assetId,
@RequestHeader(value = "X-Tenant-Id", required = false) String tenantId
) {
return videoAssetService.getUploadSessionResponse(assetId, tenantId);
} }
@GetMapping("/{assetId}/clip-jobs") @GetMapping("/{assetId}/clip-jobs")
public Object listAssetJobs(@PathVariable String assetId) { public Object listAssetJobs(
return videoAssetService.listAssetJobs(assetId); @PathVariable String assetId,
@RequestHeader(value = "X-Tenant-Id", required = false) String tenantId
) {
return videoAssetService.listAssetJobs(assetId, tenantId);
} }
@GetMapping("/{assetId}/events") @GetMapping("/{assetId}/events")
public Object listAssetEvents(@PathVariable String assetId) { public Object listAssetEvents(
return videoAssetService.listAssetEvents(assetId); @PathVariable String assetId,
@RequestHeader(value = "X-Tenant-Id", required = false) String tenantId
) {
return videoAssetService.listAssetEvents(assetId, tenantId);
} }
@PostMapping("/{assetId}/clip-jobs") @PostMapping("/{assetId}/clip-jobs")
@ -74,8 +90,11 @@ public class VideoAssetController {
@DeleteMapping("/{assetId}") @DeleteMapping("/{assetId}")
@ResponseStatus(HttpStatus.NO_CONTENT) @ResponseStatus(HttpStatus.NO_CONTENT)
public void deleteAsset(@PathVariable String assetId) { public void deleteAsset(
videoAssetService.deleteAsset(assetId); @PathVariable String assetId,
@RequestHeader(value = "X-Tenant-Id", required = false) String tenantId
) {
videoAssetService.deleteAsset(assetId, tenantId);
} }
} }

View File

@ -0,0 +1,8 @@
package org.example.videoclips.application;
public class ForbiddenException extends RuntimeException {
public ForbiddenException(String message) {
super(message);
}
}

View File

@ -123,8 +123,8 @@ public class VideoAssetService {
return response; return response;
} }
public Map<String, Object> completeUpload(String assetId, CompleteUploadRequest request) { public Map<String, Object> completeUpload(String assetId, CompleteUploadRequest request, String tenantId) {
VideoAsset asset = getAsset(assetId); VideoAsset asset = getAsset(assetId, tenantId);
if (!asset.uploadId().equals(request.uploadId())) { if (!asset.uploadId().equals(request.uploadId())) {
throw new StateConflictException("uploadId does not match the asset"); throw new StateConflictException("uploadId does not match the asset");
} }
@ -163,7 +163,7 @@ public class VideoAssetService {
} }
public Map<String, Object> createClipJob(String assetId, ClipJobRequest request, String idempotencyKey, String tenantId) { public Map<String, Object> createClipJob(String assetId, ClipJobRequest request, String idempotencyKey, String tenantId) {
VideoAsset asset = getAsset(assetId); VideoAsset asset = getAsset(assetId, tenantId);
if (asset.status() != VideoAsset.Status.UPLOADED && asset.status() != VideoAsset.Status.READY) { if (asset.status() != VideoAsset.Status.UPLOADED && asset.status() != VideoAsset.Status.READY) {
throw new StateConflictException("asset must be uploaded before a clip job can be created"); throw new StateConflictException("asset must be uploaded before a clip job can be created");
} }
@ -211,22 +211,21 @@ public class VideoAssetService {
return response; return response;
} }
public Map<String, Object> getJob(String jobId) { public Map<String, Object> getJob(String jobId, String tenantId) {
return toJobResponse(getJobEntity(jobId)); return toJobResponse(getJobEntity(jobId, tenantId));
} }
public Map<String, Object> getAssetResponse(String assetId) { public Map<String, Object> getAssetResponse(String assetId, String tenantId) {
VideoAsset asset = getAsset(assetId); VideoAsset asset = getAsset(assetId, tenantId);
return toAssetResponse(asset); return toAssetResponse(asset);
} }
public Map<String, Object> getUploadSessionResponse(String assetId) { public Map<String, Object> getUploadSessionResponse(String assetId, String tenantId) {
VideoAsset asset = getAsset(assetId); VideoAsset asset = getAsset(assetId, tenantId);
UploadSession uploadSession = requireUploadSession(asset.uploadId()); UploadSession uploadSession = requireUploadSession(asset.uploadId());
Map<String, Object> response = new LinkedHashMap<>(); Map<String, Object> response = new LinkedHashMap<>();
response.put("assetId", assetId); response.put("assetId", assetId);
response.put("uploadId", uploadSession.id()); response.put("uploadId", uploadSession.id());
response.put("providerUploadId", uploadSession.providerUploadId());
response.put("status", uploadSession.status().name()); response.put("status", uploadSession.status().name());
response.put("partSizeBytes", uploadSession.partSizeBytes()); response.put("partSizeBytes", uploadSession.partSizeBytes());
response.put("partCount", uploadSession.partCount()); response.put("partCount", uploadSession.partCount());
@ -235,8 +234,8 @@ public class VideoAssetService {
return response; return response;
} }
public Map<String, Object> listAssetJobs(String assetId) { public Map<String, Object> listAssetJobs(String assetId, String tenantId) {
getAsset(assetId); getAsset(assetId, tenantId);
List<Map<String, Object>> jobs = repository.findJobsByAssetId(assetId) List<Map<String, Object>> jobs = repository.findJobsByAssetId(assetId)
.stream() .stream()
.sorted(Comparator.comparing(ClipJob::createdAt)) .sorted(Comparator.comparing(ClipJob::createdAt))
@ -245,8 +244,8 @@ public class VideoAssetService {
return Map.of("assetId", assetId, "jobs", jobs); return Map.of("assetId", assetId, "jobs", jobs);
} }
public Map<String, Object> listAssetEvents(String assetId) { public Map<String, Object> listAssetEvents(String assetId, String tenantId) {
getAsset(assetId); getAsset(assetId, tenantId);
List<Map<String, String>> events = repository.findEventsByAggregateId(assetId).stream() List<Map<String, String>> events = repository.findEventsByAggregateId(assetId).stream()
.sorted(Comparator.comparing(JobEvent::createdAt)) .sorted(Comparator.comparing(JobEvent::createdAt))
.map(event -> Map.of( .map(event -> Map.of(
@ -259,8 +258,8 @@ public class VideoAssetService {
return Map.of("assetId", assetId, "events", events); return Map.of("assetId", assetId, "events", events);
} }
public Map<String, Object> listClips(String jobId) { public Map<String, Object> listClips(String jobId, String tenantId) {
ClipJob job = getJobEntity(jobId); ClipJob job = getJobEntity(jobId, tenantId);
List<Map<String, Object>> clips = repository.findClipsByJobId(jobId) List<Map<String, Object>> clips = repository.findClipsByJobId(jobId)
.stream() .stream()
.sorted(Comparator.comparingInt(Clip::clipIndex)) .sorted(Comparator.comparingInt(Clip::clipIndex))
@ -269,8 +268,8 @@ public class VideoAssetService {
return Map.of("jobId", job.id(), "clips", clips); return Map.of("jobId", job.id(), "clips", clips);
} }
public Map<String, Object> listJobEvents(String jobId) { public Map<String, Object> listJobEvents(String jobId, String tenantId) {
getJobEntity(jobId); getJobEntity(jobId, tenantId);
List<Map<String, String>> events = repository.findEventsByAggregateId(jobId).stream() List<Map<String, String>> events = repository.findEventsByAggregateId(jobId).stream()
.sorted(java.util.Comparator.comparing(JobEvent::createdAt)) .sorted(java.util.Comparator.comparing(JobEvent::createdAt))
.map(event -> Map.of( .map(event -> Map.of(
@ -283,8 +282,8 @@ public class VideoAssetService {
return Map.of("jobId", jobId, "events", events); return Map.of("jobId", jobId, "events", events);
} }
public Map<String, Object> cancelJob(String jobId) { public Map<String, Object> cancelJob(String jobId, String tenantId) {
ClipJob job = getJobEntity(jobId); ClipJob job = getJobEntity(jobId, tenantId);
if (job.status() == ClipJobStatus.SUCCEEDED || job.status() == ClipJobStatus.FAILED || job.status() == ClipJobStatus.CANCELLED) { if (job.status() == ClipJobStatus.SUCCEEDED || job.status() == ClipJobStatus.FAILED || job.status() == ClipJobStatus.CANCELLED) {
throw new StateConflictException("job can no longer be cancelled"); throw new StateConflictException("job can no longer be cancelled");
} }
@ -294,8 +293,8 @@ public class VideoAssetService {
return toJobResponse(updated); return toJobResponse(updated);
} }
public void deleteAsset(String assetId) { public void deleteAsset(String assetId, String tenantId) {
VideoAsset asset = getAsset(assetId); VideoAsset asset = getAsset(assetId, tenantId);
repository.findJobsByAssetId(assetId).stream() repository.findJobsByAssetId(assetId).stream()
.filter(job -> job.status() != ClipJobStatus.SUCCEEDED .filter(job -> job.status() != ClipJobStatus.SUCCEEDED
&& job.status() != ClipJobStatus.FAILED && job.status() != ClipJobStatus.FAILED
@ -321,12 +320,12 @@ public class VideoAssetService {
appendJobEvent(assetId, "ASSET_DELETED", "Asset deleted and associated processing cancelled"); appendJobEvent(assetId, "ASSET_DELETED", "Asset deleted and associated processing cancelled");
} }
public SignedUrlResponse createClipDownloadUrl(String clipId) { public SignedUrlResponse createClipDownloadUrl(String clipId, String tenantId) {
Clip clip = repository.findClipById(clipId); Clip clip = repository.findClipById(clipId);
if (clip == null) { if (clip == null) {
throw new NotFoundException("clip not found: " + clipId); throw new NotFoundException("clip not found: " + clipId);
} }
getAsset(clip.assetId()); getAsset(clip.assetId(), tenantId);
Instant expiresAt = Instant.now().plus(properties.getDownloadUrlTtlMinutes(), ChronoUnit.MINUTES); Instant expiresAt = Instant.now().plus(properties.getDownloadUrlTtlMinutes(), ChronoUnit.MINUTES);
return new SignedUrlResponse( return new SignedUrlResponse(
clipId, clipId,
@ -336,39 +335,51 @@ public class VideoAssetService {
} }
public void markJobRunning(String jobId) { public void markJobRunning(String jobId) {
ClipJob job = getJobEntity(jobId); ClipJob job = getJobEntityInternal(jobId);
repository.saveJob(job.withStatus(ClipJobStatus.RUNNING).withStartedAt(Instant.now())); repository.saveJob(job.withStatus(ClipJobStatus.RUNNING).withStartedAt(Instant.now()));
appendEvent(jobId, "STARTED", "Clip processing started"); appendEvent(jobId, "STARTED", "Clip processing started");
} }
public void markJobProgress(String jobId, int progress) { public void markJobProgress(String jobId, int progress) {
ClipJob job = getJobEntity(jobId); ClipJob job = getJobEntityInternal(jobId);
repository.saveJob(job.withProgress(progress)); repository.saveJob(job.withProgress(progress));
appendEvent(jobId, "PROGRESS", "Job progress is " + progress + "%"); appendEvent(jobId, "PROGRESS", "Job progress is " + progress + "%");
} }
public void completeJob(String jobId, List<Clip> clips) { public void completeJob(String jobId, List<Clip> clips) {
ClipJob job = getJobEntity(jobId); ClipJob job = getJobEntityInternal(jobId);
clips.forEach(repository::saveClip); clips.forEach(repository::saveClip);
repository.saveJob(job.withStatus(ClipJobStatus.SUCCEEDED).withProgress(100).withFinishedAt(Instant.now())); repository.saveJob(job.withStatus(ClipJobStatus.SUCCEEDED).withProgress(100).withFinishedAt(Instant.now()));
VideoAsset asset = getAsset(job.assetId()); VideoAsset asset = getAssetInternal(job.assetId());
repository.saveAsset(asset.withStatus(VideoAsset.Status.READY)); repository.saveAsset(asset.withStatus(VideoAsset.Status.READY));
appendEvent(jobId, "SUCCEEDED", "Generated " + clips.size() + " clips"); appendEvent(jobId, "SUCCEEDED", "Generated " + clips.size() + " clips");
} }
public void failJob(String jobId, String message) { public void failJob(String jobId, String message) {
ClipJob job = getJobEntity(jobId); ClipJob job = getJobEntityInternal(jobId);
repository.saveJob(job.withStatus(ClipJobStatus.FAILED).withErrorMessage(message).withFinishedAt(Instant.now())); repository.saveJob(job.withStatus(ClipJobStatus.FAILED).withErrorMessage(message).withFinishedAt(Instant.now()));
VideoAsset asset = getAsset(job.assetId()); VideoAsset asset = getAssetInternal(job.assetId());
repository.saveAsset(asset.withStatus(VideoAsset.Status.UPLOADED)); repository.saveAsset(asset.withStatus(VideoAsset.Status.UPLOADED));
appendEvent(jobId, "FAILED", message); appendEvent(jobId, "FAILED", message);
} }
public boolean isCancellationRequested(String jobId) { public boolean isCancellationRequested(String jobId) {
return getJobEntity(jobId).status() == ClipJobStatus.CANCEL_REQUESTED; return getJobEntityInternal(jobId).status() == ClipJobStatus.CANCEL_REQUESTED;
} }
public VideoAsset getAsset(String assetId) { public VideoAsset getAsset(String assetId, String tenantId) {
VideoAsset asset = getAssetInternal(assetId);
assertTenantAccess(asset.tenantId(), tenantId);
return asset;
}
private ClipJob getJobEntity(String jobId, String tenantId) {
ClipJob job = getJobEntityInternal(jobId);
assertTenantAccess(job.tenantId(), tenantId);
return job;
}
private VideoAsset getAssetInternal(String assetId) {
VideoAsset asset = repository.findAssetById(assetId); VideoAsset asset = repository.findAssetById(assetId);
if (asset == null || asset.status() == VideoAsset.Status.DELETED) { if (asset == null || asset.status() == VideoAsset.Status.DELETED) {
throw new NotFoundException("asset not found: " + assetId); throw new NotFoundException("asset not found: " + assetId);
@ -376,7 +387,7 @@ public class VideoAssetService {
return asset; return asset;
} }
private ClipJob getJobEntity(String jobId) { private ClipJob getJobEntityInternal(String jobId) {
ClipJob job = repository.findJobById(jobId); ClipJob job = repository.findJobById(jobId);
if (job == null) { if (job == null) {
throw new NotFoundException("job not found: " + jobId); throw new NotFoundException("job not found: " + jobId);
@ -446,6 +457,13 @@ public class VideoAssetService {
return tenantId == null || tenantId.isBlank() ? DEFAULT_TENANT_ID : tenantId; return tenantId == null || tenantId.isBlank() ? DEFAULT_TENANT_ID : tenantId;
} }
private void assertTenantAccess(String resourceTenantId, String requestTenantId) {
String normalizedTenantId = normalizeTenantId(requestTenantId);
if (!resourceTenantId.equals(normalizedTenantId)) {
throw new ForbiddenException("tenant is not allowed to access this resource");
}
}
private void appendJobEvent(String aggregateId, String eventType, String message) { private void appendJobEvent(String aggregateId, String eventType, String message) {
repository.saveJobEvent(new JobEvent(nextId("evt"), aggregateId, eventType, message, Instant.now())); repository.saveJobEvent(new JobEvent(nextId("evt"), aggregateId, eventType, message, Instant.now()));
} }

View File

@ -40,6 +40,7 @@ class TenantQuotaControllerTest {
String uploadId = JsonTestHelper.read(assetResponse, "$.upload.uploadId"); String uploadId = JsonTestHelper.read(assetResponse, "$.upload.uploadId");
mockMvc.perform(post("/v1/video-assets/{assetId}/uploads:complete", assetId) mockMvc.perform(post("/v1/video-assets/{assetId}/uploads:complete", assetId)
.header("X-Tenant-Id", "tenant-a")
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content(""" .content("""
{ {

View File

@ -73,7 +73,7 @@ class VideoAssetControllerTest {
mockMvc.perform(get("/v1/video-assets/{assetId}/upload-session", assetId)) mockMvc.perform(get("/v1/video-assets/{assetId}/upload-session", assetId))
.andExpect(status().isOk()) .andExpect(status().isOk())
.andExpect(jsonPath("$.uploadId").value(uploadId)) .andExpect(jsonPath("$.uploadId").value(uploadId))
.andExpect(jsonPath("$.providerUploadId").value(uploadId)) .andExpect(jsonPath("$.providerUploadId").doesNotExist())
.andExpect(jsonPath("$.status").value("COMPLETED")); .andExpect(jsonPath("$.status").value("COMPLETED"));
mockMvc.perform(get("/v1/video-assets/{assetId}/events", assetId)) mockMvc.perform(get("/v1/video-assets/{assetId}/events", assetId))
@ -330,4 +330,30 @@ class VideoAssetControllerTest {
.andExpect(status().isNotFound()) .andExpect(status().isNotFound())
.andExpect(jsonPath("$.type").value("https://api.example.com/problems/not-found")); .andExpect(jsonPath("$.type").value("https://api.example.com/problems/not-found"));
} }
@Test
void forbidsCrossTenantAssetAccess() throws Exception {
String assetResponse = mockMvc.perform(post("/v1/video-assets")
.header("X-Tenant-Id", "tenant-a")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"fileName": "tenant-a.mp4",
"contentType": "video/mp4",
"contentLengthBytes": 4096,
"checksumSha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
}
"""))
.andExpect(status().isCreated())
.andReturn()
.getResponse()
.getContentAsString();
String assetId = JsonTestHelper.read(assetResponse, "$.assetId");
mockMvc.perform(get("/v1/video-assets/{assetId}", assetId)
.header("X-Tenant-Id", "tenant-b"))
.andExpect(status().isForbidden())
.andExpect(jsonPath("$.type").value("https://api.example.com/problems/forbidden"));
}
} }